Svelte 现实世界指南(全)
原文:zh.annas-archive.org/md5/14dc6d5ba3099ee8ed407418d0a0711b
译者:飞龙
协议:CC BY-NC-SA 4.0
前言
在当今的数字时代,网络开发不断演变,几乎每天都有新的工具和框架出现。其中,Svelte 独树一帜,在最近的一次开发者调查中被选为最受欢迎的框架(survey.stackoverflow.co/2022/#section-most-loved-dreaded-and-wanted-web-frameworks-and-technologies )。Svelte 通过优化性能和提供直观的设计,以及功能齐全,带来了全新的视角。
虽然其他资源涵盖了 Svelte 的众多功能,但本书提供了一个独特的视角和方法。我们将深入探讨关键 Svelte 功能,解密核心概念。通过实际、真实的案例,我们不仅会教授“如何”,还会解释每种方法的“为什么”。通过理解底层原理和思维过程,你将能够无缝地将所学知识整合到你的 Svelte 项目中。
本书面向对象
本书专为具有 JavaScript、CSS 和一般网络开发实践基础知识的网络开发人员和软件工程师量身定制。无论你是 Svelte 的新手,渴望深入了解,还是已经接触过其基础知识但希望通过实际案例和模式提升你的专业知识,这本指南都是为你准备的。除了提供指导外,内容还深入探讨了每个概念背后的“为什么”和“如何”,确保读者不仅理解材料,而且能够有效地在专业环境和各种项目中应用它们。如果你准备好不仅学习 Svelte,还要掌握其复杂性和实际应用,那么这本书是你下一本必读的书籍。
本书涵盖内容
第一章 ,Svelte 中的生命周期 ,概述了 Svelte 的生命周期、它们各自的功能、调用它们的规则以及重用和组合这些生命周期函数的策略。
第二章 ,实现样式和主题 ,深入探讨了六种独特的样式化 Svelte 组件的方法。你还将学习如何定义主题以及启用用户自定义 Svelte 组件的必要知识。
第三章 ,管理属性和状态 ,加深了你对于 Svelte 中属性和状态的理解。这一章节解密了属性、状态和绑定,并讨论了一向绑定和双向绑定的区别。它还展示了从属性推导状态的方法。
第四章 ,组件组合 ,提供了从父组件控制子组件内容的技巧。你将探索 <slot> 元素以及各种 Svelte 特殊元素,如 <svelte:self> 和 <svelte:fragment>。
第五章 , 使用动作创建自定义事件 ,开始探索 Svelte 动作的三章内容。本章首先探讨了使用 Svelte 动作创建自定义事件的想法。
第六章 , 使用动作集成库 ,提供了使用动作将第三方 JavaScript 库集成到 Svelte 中的实用指南。
第七章 , 使用 Svelte 动作进行渐进式增强 ,解释了渐进式增强的概念,并帮助你理解如何利用 Svelte 动作逐步增强你的 Svelte 应用程序。
第八章 , 上下文与存储 ,深入探讨了 Svelte 上下文和存储。你将了解何时以及如何使用 Svelte 上下文和存储。
第九章 , 实现自定义存储 ,通过实用的分步指南教你如何实现自定义 Svelte 存储,并在过程中通过多个真实世界示例进行讲解。
第十章 , 使用 Svelte 存储进行状态管理 ,为你提供了在 Svelte 应用程序中管理应用程序状态的实际技巧。你还将学习如何在 Svelte 中使用第三方状态管理库。
第十一章 , 无渲染组件 ,探讨了无渲染组件的概念,这是一种不渲染任何自身 HTML 元素的可重用组件。我们将系统地通过实现此类组件。
第十二章 , 存储与动画 ,探讨了内置的tweened和spring存储。你将学习如何在 Svelte 应用程序中应用它们,以及如何为这些动画存储自定义插值。
第十三章 , 使用过渡 ,提供了对 Svelte 中过渡的全面理解。你将学习如何在 Svelte 中使用过渡,何时以及如何播放过渡,以及它们在底层是如何工作的。
第十四章 , 探索自定义过渡 ,探讨了在 Svelte 中编写自定义过渡的想法。你将了解 Svelte 过渡合约,并通过实际示例,你将逐步指导创建 Svelte 中的自定义过渡。
第十五章 , 过渡中的无障碍性 ,揭示了过渡中的无障碍性考虑,特别是针对有前庭功能障碍的用户。你将获得制作尊重用户偏好并满足所有人的负责任过渡的见解。
为了充分利用本书
你需要具备基本的 Web 开发知识,并对 JavaScript、CSS 和 HTML 有基本的了解。
本书涵盖的软件/硬件
操作系统要求
Svelte 4
Windows, macOS, 或 Linux
JavaScript
如果你正在使用本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助你避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
你可以从 GitHub(github.com/PacktPublishing/Real-World-Svelte )下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,可在github.com/PacktPublishing/ 找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块是这样设置的:
const folder = [
{ type: 'file', name: 'a.js' },
{ type: 'file', name: 'b.js' },
{ type: 'folder', name: 'c', children: [
{ type: 'file', name: 'd.js' },
]},
];
当我们希望将你的注意力引到代码块的一个特定部分时,相关的行或项目将被设置为粗体:
<!-- filename: JsonTree.svelte -->
<script>
export let data;
</script>
<ul>
{#each Object.entries(data) as [key, value]}
<li>
{key}:
{#if typeof value === 'object'}
<svelte:self data={value} />
{:else}
{value}
{/if}
<li>
{/each}
</ul>
<form>元素默认情况下,当你点击action属性时,它会携带<form>元素内<input>元素中填写的值。”
小贴士或重要提示
它看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈 : 如果你对此书的任何方面有疑问,请通过customercare@packtpub.com 给我们发邮件,并在邮件的主题中提及书名。
勘误 : 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,如果你能向我们报告这一点,我们将不胜感激。请访问www.packtpub.com/support/errata 并填写表格。
盗版 : 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者 : 如果你在一个你擅长的主题上,并且你感兴趣的是撰写或为本书做出贡献,请访问authors.packtpub.com 。
分享你的想法
一旦你阅读了《Real-World Svelte》,我们很乐意听到你的想法!请点击此处直接进入此书的亚马逊评论页面 并分享你的反馈。
你的评价对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢你购买这本书!
你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?
你的电子书购买是否与你的选择设备不兼容?
别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从你喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠远不止于此,你还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。
按照以下简单步骤获取福利:
扫描下面的二维码或访问以下链接
packt.link/free-ebook/9781804616031
提交你的购买证明
就这些!我们将直接将你的免费 PDF 和其他福利发送到你的邮箱。
第一部分:编写 Svelte 组件
在本节中,我们将为编写 Svelte 组件打下基础。我们将从深入研究 Svelte 组件的生命周期开始。然后,我们将学习如何为我们的 Svelte 组件添加样式和主题。之后,我们将探讨组件之间数据传递的复杂性,最后总结将组件组合成一个统一的 Svelte 应用程序的技术。
本部分包含以下章节:
第一章 , Svelte 中的生命周期
第二章 , 实现样式和主题
第三章 , 管理属性和状态
第四章 , 组件编写
第一章:Svelte 中的生命周期
Svelte 是一个前端框架。你可以使用 Svelte 来构建网站和 Web 应用程序。一个 Svelte 应用程序由组件组成。你可以在具有.svelte扩展名的文件中编写 Svelte 组件。每个.svelte文件都是一个 Svelte 组件。
当你创建和使用一个 Svelte 组件时,该组件会经历组件生命周期的各个阶段。Svelte 提供了生命周期函数,允许你钩入组件的不同阶段。
在本章中,我们将首先讨论 Svelte 中的各种生命周期和生命周期函数。在心中有了对生命周期的清晰认识后,你将学习使用生命周期函数的基本规则。这是非常重要的,因为你会发现这种理解将使我们能够以很多创造性的方式使用生命周期函数。
本章包含以下主题的章节:
Svelte 生命周期函数是什么?
调用生命周期函数的规则
如何重用和组合生命周期函数
技术要求
编写 Svelte 应用程序非常简单,不需要任何付费工具。尽管大多数付费工具都有附加价值,但我们决定只使用免费工具,以便您无限制地获取本书的内容。
你将需要以下内容:
本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter01
所有章节的代码可以在github.com/PacktPublishing/Real-World-Svelte 找到。
理解 Svelte 的生命周期函数
当使用 Svelte 组件时,它在其生命周期中会经历不同的阶段:挂载、更新和销毁。这类似于人类。我们一生中会经历各种阶段,如出生、成长、老年和死亡。我们称这些不同的阶段为生命周期。
在我们讨论 Svelte 中的生命周期之前,让我们先看看一个 Svelte 组件。
<script>
import { onMount, beforeUpdate, afterUpdate, onDestroy } from 'svelte';
let count = 0;
onMount(() => { console.log('onMount!'); });
beforeUpdate(() => { console.log('beforeUpdate!'); });
afterUpdate(() => { console.log('afterUpdate!'); });
onDestroy(() => { console.log('onDestroy!'); });
</script>
<button on:click={() => { count ++; }}>
Counter: {count}
</button>
你能告诉我代码的每个部分何时执行吗?
代码的并非所有部分都会同时执行;代码的不同部分会在组件生命周期的不同阶段执行。
Svelte 组件有四个不同的生命周期阶段:初始化、挂载、更新和销毁。
初始化组件
当你创建一个组件时,组件首先会进入初始化阶段。你可以将其视为设置阶段,此时组件会设置其内部状态。
这就是第 2-7 行正在执行的地方。
声明并初始化 count 变量。调用 onMount、beforeUpdate、afterUpdate 和 onDestroy 生命周期函数,并传入回调函数,以在组件生命周期的特定阶段注册它们。
在组件初始化后,Svelte 开始在模板中创建元素,在这种情况下,是一个 <button> 元素和用于 "Counter: " 和 {count} 的文本元素。
挂载组件
在所有元素创建完成后,Svelte 将按顺序将它们插入到 文档对象模型 (DOM) 中。这被称为挂载阶段,其中元素被挂载到 DOM 上。
如果你向一个元素添加 Svelte 动作,那么这些动作将与元素一起被调用:
<script>
function action(node) {}
</script>
<div use:action>
我们将在 第五章 到 7 中更深入地探讨 Svelte 动作。
如果你向元素添加事件监听器,这就是 Svelte 将事件监听器附加到元素的时候。
在前一个示例中,Svelte 在将按钮插入 DOM 之后将其附加到 click 事件监听器上。
当我们向一个元素添加绑定时,绑定的变量会根据元素的值进行更新:
<script>
let element;
</script>
<div bind:this={element} />
这时,element 变量将更新为 Svelte 创建的 <div> 元素的引用。
如果你向一个元素添加转换,这就是转换被初始化并开始播放的时候。
以下是一个向元素添加转换的示例。你可以使用 transition:, in:, 和 out: 指令向元素添加转换。我们将在 第十三章 到 15 中更深入地探讨 Svelte 转换:
<div in:fade />
在处理完所有指令,use:(动作),on:(事件监听器),bind: 绑定,in:,transition:(转换)之后,通过调用在 onMount 生命周期函数中注册的所有函数,挂载阶段结束。
这时,第 4 行的函数将被执行,你将在日志中看到打印出 "onMount!"。
更新组件
当你点击按钮时,click 事件监听器被调用。第 9 行的函数被执行。count 变量被增加。
在 Svelte 根据最新的 count 变量值修改 DOM 之前,将调用在 beforeUpdate 生命周期函数中注册的函数。
第 5 行的函数将被执行,你将在日志中看到打印出文本 "beforeUpdate!"。
在此阶段,如果你尝试检索按钮内的文本内容,它仍然是 "``Counter: 0"。
然后 Svelte 继续修改 DOM,将按钮的文本内容更新为 "``Counter: 1"。
在更新组件内的所有元素之后,Svelte 会调用在 afterUpdate 生命周期函数中注册的所有函数。
第 6 行的函数被执行,你将在日志中看到打印出文本 "afterUpdate!"。
如果你再次点击按钮,Svelte 将通过另一个周期的 beforeUpdate,然后更新 DOM 元素,然后 afterUpdate。
销毁组件
当条件成立时,向用户显示的条件性组件将保持不变;当条件不再成立时,Svelte 将继续销毁组件。
假设我们例子中的组件现在进入了销毁阶段。
Svelte 会调用在 onDestroy 生命周期函数中注册的所有函数。第 7 行的函数会被执行,你将在日志中看到打印出的文本 "onDestroy!"。
之后,Svelte 会从 DOM 中移除元素。
如果需要,Svelte 会清理指令,例如移除事件监听器和从动作中调用销毁方法。
就这样!如果你尝试再次创建组件,新的周期会再次开始。
Svelte 组件的生命周期从初始化、挂载、更新和销毁开始。Svelte 提供生命周期方法,允许你在组件的不同阶段运行函数。
由于生命周期函数只是从 'svelte' 导出的函数,你可以在任何地方导入和使用它们吗?导入和使用它们时有什么规则或约束吗?
让我们来看看。
调用生命周期函数的一条规则
调用组件生命周期函数的唯一规则是,你应该在组件初始化期间调用它们。如果没有组件正在初始化,Svelte 会通过抛出错误来抱怨。
让我们看看以下示例:
<script>
import { onMount } from 'svelte';
function buttonClicked() {
onMount(() => console.log('onMount!'));
}
</script>
<button on:click={buttonClicked} />
当你点击按钮时,它会调用 buttonClicked,这将调用 onMount。由于在调用 onMount 时没有组件正在初始化(在你点击按钮时,上面的组件已经初始化并挂载),Svelte 会抛出错误:
Error: Function called outside component initialization
是的,Svelte 不允许在组件初始化阶段之外调用生命周期函数。这条规则规定了你可以何时调用生命周期函数。它没有规定在哪里或如何调用生命周期函数。这允许我们重构生命周期函数并以其他方式调用它们。
重构生命周期函数
如果你仔细观察调用生命周期函数的规则,你会注意到它关乎何时调用它们,而不是在哪里调用它们。
在 <script> 标签的顶层调用生命周期函数并不是必需的。
在以下示例中,setup 函数在组件初始化期间被调用,然后反过来调用 onMount 函数:
<script>
import { onMount } from 'svelte';
setup();
function setup() {
onMount(() => console.log('onMount!'));
}
</script>
由于组件仍在初始化中,这是完全正常的。
同样,在组件内部导入 onMount 函数也不是必需的。正如你在以下示例中看到的,你可以在另一个文件中导入它;只要在组件初始化期间调用 onMount 函数,那就完全没问题:
// file-a.js
import { onMount } from 'svelte';
export function setup() {
onMount(() => console.log('onMount!'));
}
在前面的代码片段中,我们将之前定义的setup函数移动到一个名为file-a.js的新模块中。然后,在原始的 Svelte 组件中,我们不是定义setup函数,而是从file-a.js中导入它,如下面的代码片段所示:
<script>
import { setup } from './file-a.js';
setup();
</script>
由于setup函数调用了onMount函数,同样的规则也适用于setup函数!您不能再在组件初始化之外调用setup函数。
应该注册哪个组件?
仅看setup函数,您可能会想知道,当您调用onMount函数时,Svelte 是如何知道您指的是哪个组件的生命周期的?
内部,Svelte 跟踪哪个组件正在初始化。当您调用生命周期函数时,它将您的函数注册到正在初始化的组件的生命周期中。
因此,相同的setup函数可以在不同的组件中调用,并为不同的组件注册onMount函数。
这解锁了本章的第一个模式:重用生命周期函数。
在 Svelte 组件中重用生命周期函数
在上一节中,我们了解到我们可以将生命周期函数的调用提取到一个函数中,并在其他组件中重用该函数。
让我们看看一个例子。在这个例子中,组件在屏幕上添加了 5 秒后,将调用showPopup函数。我想在其他组件中重用调用showPopup的逻辑:
<script>
import { onMount } from 'svelte';
import { showPopup } from './popup';
onMount(() => {
const timeoutId = setTimeout(() => {
showPopup();
}, 5000);
return () => clearTimeout(timeoutId);
});
</script>
这里,我可以将逻辑提取到一个函数中,showPopupOnMount:
// popup-on-mount.js
import { onMount } from 'svelte';
import { showPopup } from './popup';
export function showPopupOnMount() {
onMount(() => {
const timeoutId = setTimeout(() => {
showPopup();
}, 5000);
return () => clearTimeout(timeoutId);
});
}
现在,我可以导入这个函数并在任何组件中重用它:
<script>
import { showPopupOnMount } from './popup-on-mount';
showPopupOnMount();
</script>
您可能想知道,为什么不只提取回调函数并重用它呢?
// popup-on-mount.js
import { showPopup } from './popup';
export function showPopupOnMount() {
const timeoutId = setTimeout(() => {
showPopup();
}, 5000);
return () => clearTimeout(timeoutId);
}
在这里,我们将setTimeout和clearTimeout逻辑提取到showPopupOnMount中,并将该函数传递给onMount:
<script>
import { onMount } from 'svelte';
import { showPopupOnMount } from './popup-on-mount';
onMount(showPopupOnMount);
</script>
在我看来,重构和重用的第二种方法不如第一种方法好。将整个生命周期函数的调用提取到函数中有几个优点,因为它允许您做更多的事情:
您可以将不同的输入参数传递给您的生命周期函数。
假设您希望允许不同的组件自定义显示弹出窗口前的持续时间。以这种方式传递要容易得多:
<script>
import { showPopupOnMount } from './popup-on-mount';
showPopupOnMount(2000); // change it to 2s
</script>
您可以从函数中返回值。
假设您想返回onMount函数中使用的timeoutId,这样您就可以在用户在组件中的任何按钮上点击时取消它。
如果您只是重用回调函数,这将几乎不可能做到,因为回调函数返回的值将用于注册onDestroy生命周期函数:
<script>
import { showPopupOnMount } from './popup-on-mount';
const timeoutId = showPopupOnMount(2000);
</script>
<button on:click={() => clearTimeout(timeoutId)} />
看看这样写,实现它以返回任何内容是多么容易:
// popup-on-mount.js
export function showPopupOnMount(duration) {
let timeoutId;
onMount(() => {
timeoutId = setTimeout(() => {
showPopup();
}, duration ?? 5000);
return () => clearTimeout(timeoutId);
});
return timeoutId;
}
您可以将更多逻辑与生命周期函数一起封装。
有时,您在生命周期函数回调函数中的代码在隔离环境中不起作用;它会与其他变量交互并修改它们。为了重用此类生命周期函数,必须将这些变量和逻辑封装到一个可重用的函数中。
为了说明这一点,让我们看看一个新的例子。
在这里,我有一个计数器,当组件被添加到屏幕上时开始计数:
<script>
import { onMount } from 'svelte';
let counter = 0;
onMount(() => {
const intervalId = setInterval(() => counter++, 1000);
return () => clearInterval(intervalId);
});
</script>
<span>{counter}</span>
counter 变量与 onMount 生命周期函数相关联;为了重用此逻辑,应将 counter 变量和 onMount 函数一起提取到一个可重用的函数中:
import { writable } from 'svelte/store';
import { onMount } from 'svelte';
export function startCounterOnMount() {
const counter = writable(0);
onMount(() => {
const intervalId = setInterval(() => counter.update($counter => $counter + 1), 1000);
return () => clearInterval(intervalId);
});
return counter;
}
在这个例子中,我们使用一个 writable Svelte 存储使 counter 变量变得响应式。我们将在本书的 第三部分 中更深入地探讨 Svelte 存储。
目前,您需要理解的是,Svelte 存储允许 Svelte 跨模块跟踪变量的变化,并且您可以通过在 Svelte 存储变量前加上 $ 来订阅和检索存储的值。例如,如果您有一个名为 counter 的 Svelte 存储,那么要获取 Svelte 存储的值,您需要使用 $counter 变量。
现在,我们可以在任何 Svelte 组件中使用 startCounterOnMount 函数:
<script>
import { startCounterOnMount } from './counter';
const counter = startCounterOnMount();
</script>
<span>{$counter}</span>
希望我已经说服您提取生命周期函数调用的优点。让我们通过一个例子来试试。
练习 1 – 更新计数器
在下面的示例代码中,我想知道组件经过了多少次更新周期。
利用每次组件经过更新周期时都会调用 afterUpdate 回调函数的事实,我创建了一个计数器,每次 afterUpdate 回调函数被调用时都会递增。
为了帮助我们仅测量特定用户操作的更新计数,我们有开始测量和停止测量的函数,因此更新计数器仅在测量时递增:
<script>
import { afterUpdate } from 'svelte';
let updateCount = 0;
let measuring = false;
afterUpdate(() => {
if (measuring) {
updateCount ++;
}
});
function startMeasuring() {
updateCount = 0;
measuring = true;
}
function stopMeasuring() {
measuring = false;
}
</script>
<button on:click={startMeasuring}>Measure</button>
<button on:click={stopMeasuring}>Stop</button>
<span>Updated {updateCount} times</span>
为了重用 counter: 的所有逻辑——更新周期的计数以及测量开始和停止——我们应该将所有这些移动到一个函数中,最终看起来像这样:
<script>
import { createUpdateCounter } from './update-counter';
const { updateCount, startMeasuring, stopMeasuring } = createUpdateCounter();
</script>
<button on:click={startMeasuring}>Measure</button>
<button on:click={stopMeasuring}>Stop</button>
<span>Updated {$updateCount} times</span>
更新计数器返回一个包含 updateCount 变量和 startMeasuring 以及 stopMeasuring 函数的对象。
createUpdateCounter 函数的实现留作练习,您可以在 github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter01/01-update-counter 查看答案。
我们已经学习了如何提取生命周期函数并重用它,所以让我们更进一步,在下一个模式中重用多个生命周期函数:组合生命周期函数。
将生命周期函数组合成可重用钩子
到目前为止,我们主要讨论了重用单个生命周期函数。然而,没有任何阻止我们将多个生命周期函数组合起来执行一个函数。
这里摘录了来自 svelte.dev/examples/update 的示例。该示例显示了一个消息列表。当新消息被添加到列表中时,容器会自动滚动到最底部以显示新消息。在代码片段中,我们看到这种自动滚动行为是通过结合使用 beforeUpdate 和 afterUpdate 实现的:
<script>
import { beforeUpdate, afterUpdate } from 'svelte';
let div;
let autoscroll;
beforeUpdate(() => {
autoscroll = div && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20);
});
afterUpdate(() => {
if (autoscroll) div.scrollTo(0, div.scrollHeight);
});
</script>
<div bind:this={div} />
要在其他组件中重用这个 autoscroll 逻辑,我们可以将 beforeUpdate 和 afterUpdate 逻辑一起提取到一个新函数中:
export function setupAutoscroll() {
let div;
let autoscroll;
beforeUpdate(() => {
autoscroll = div && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20);
});
afterUpdate(() => {
if (autoscroll) div.scrollTo(0, div.scrollHeight);
});
return {
setDiv(_div) {
div = _div;
},
};
}
然后,我们可以在任何组件中使用提取的函数,setupAutoScroll:
<script>
import { setupAutoscroll } from './autoscroll';
const { setDiv } = setupAutoscroll();
let div;
$: setDiv(div);
</script>
<div bind:this={div} />
在重构后的 setupAutoscroll 函数中,我们返回一个 setDiv 函数,以便我们可以在 setupAutoscroll 函数内部更新 div 的引用。
正如你所见,通过遵循在组件初始化期间调用生命周期函数的一条规则,你可以将多个生命周期函数组合成可重用的钩子。到目前为止你所学的足以组合生命周期函数,但未来还有更多替代方案。在接下来的章节中,你将探索第 5 章 中的 Svelte 动作和第 8 章 中的 Svelte 存储,进一步扩展你的选择。以下是一些这些替代方案的预览。
另一种实现方式可能是将 div 设为一个可写存储,并在 setupAutoscroll 函数中返回它。这样,我们就可以直接绑定到 div 可写存储,而无需手动调用 setDiv。
或者,我们可以返回一个遵循 Svelte 动作合约的函数,并在 div 上使用该动作:
export function setupAutoscroll() {
let div;
// ...
return function (node) {
div = node;
return {
destroy() {
div = undefined;
},
};
};
}
setupAutoscroll 现在返回一个动作,我们使用这个动作在我们的 div 容器上:
<script>
import { setupAutoscroll } from './autoscroll';
const autoscroll = setupAutoscroll();
</script>
<div use:autoscroll />
我们将在本书的后面部分更详细地讨论 Svelte 动作合约。
我们已经看到如何将生命周期函数提取到单独的文件中,并在多个 Svelte 组件中重用它。目前,组件独立调用生命周期函数,并作为独立单元运行。是否有可能同步或协调使用相同生命周期函数的组件之间的动作?让我们来找出答案。
在组件间协调生命周期函数
由于我们在组件间重用相同的函数,我们可以全局跟踪使用相同生命周期函数的组件。
让我给你举一个例子。在这里,我想跟踪屏幕上有多少组件正在使用我们的生命周期函数。
要计算组件的数量,我们可以在模块级别定义一个变量,并在我们的生命周期函数中更新它:
import { onMount, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
let counter = writable(0);
export function setupGlobalCounter() {
onMount(() => counter.update($counter => $counter + 1));
onDestroy(() => counter.update($counter => $counter - 1));
return counter;
}
由于 counter 变量是在 setupGlobalCounter 函数外部声明的,因此相同的 counter 变量实例被用于并共享在所有组件之间。
当任何组件被挂载时,它将增加 counter,任何引用 counter 的组件都将更新为最新的计数器值。
当你想要在组件之间设置一个共享的通信通道并在组件被销毁时在onDestroy中将其拆除时,这种模式非常有用。
让我们尝试在我们的下一个练习中使用这项技术。
练习 2 – 滚动阻止器
通常情况下,当你将一个弹出组件添加到屏幕上时,你希望文档不可滚动,这样用户就能专注于弹出窗口,并且只在该弹出窗口内滚动。
这可以通过将body的overflow CSS 属性设置为"hidden"来实现。
编写一个可重用的函数,该函数由弹出组件使用,在弹出组件安装时禁用滚动。当弹出组件被销毁时,恢复初始的overflow属性值。
请注意,屏幕上可以同时安装多个弹出组件,因此只有当所有弹出窗口都被销毁时,你才应该恢复overflow属性值。
你可以在github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter01/02-scroll-blocker 查看答案。
摘要
在本章中,我们探讨了 Svelte 组件的生命周期。我们看到了组件生命周期的不同阶段,并学习了生命周期函数回调将在何时被调用。
我们还介绍了调用生命周期函数的规则。这有助于我们实现生命周期函数的重用和组合的不同模式。
在下一章中,我们将开始探讨为 Svelte 组件进行样式化和主题化的不同模式。
第二章:实现样式和主题
没有样式的情况下,组件内的 h1 元素将与其他组件中的 h1 元素看起来相同。Svelte 允许您使用 层叠样式表 (CSS ),这是一种用于样式化和格式化网页内容的语言,来设置您的元素样式,使它们具有不同的外观和感觉。
在本章中,我们将首先讨论不同的 Svelte 组件样式设置方法。然后,我们将看到一些示例,包括将流行的 CSS 框架 Tailwind CSS 集成到 Svelte 中。
接下来,我们将讨论主题。当您在 Svelte 组件中一致地应用一组样式时,您将在组件中看到整体样式主题。我们将讨论如何跨组件同步样式,以及如何允许组件用户自定义样式。
到本章结束时,您将学会各种样式设置方法,并能够根据场景选择合适的方案和适用正确的方法。
本章包括以下内容:
技术要求
您可以在 GitHub 上找到本章使用的代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter02 。
以六种不同的方式设置 Svelte 组件的样式
在 Svelte 组件中,您有定义结构和内容的元素。通过样式设置,您可以改变元素的外观和感觉,超出浏览器的默认设置。
Svelte 组件可以通过六种不同的方式来设置样式。让我们来探索在 Svelte 组件内部应用样式的不同方法。
使用 style 属性进行样式设置
首先,您可以通过 style 属性向元素添加内联样式:
<div style="color: blue;" />
前面的代码片段将 div 内文本的颜色变为蓝色。
与 HTML 元素中的 style 属性类似,您可以在 style 属性中添加多个 CSS 样式声明:
<div style="color: blue; font-size: 2rem;" />
在 Svelte 中添加多个 CSS 样式声明的语法与在 HTML 中做的方式相同。在前面的代码片段中,我们将 div 内的文本颜色改为蓝色,并设置为 2 rem 大小。
style 属性的值是一个字符串。您可以使用动态表达式来构建 style 属性:
<div style="color: {color};" />
当您在 style 属性中拥有多个 CSS 样式声明时,有时会变得杂乱无章:
<div style="color: {color}; font-size: {fontSize}; background: {background}; border-top: {borderTop};" />
使用 style: 指令
Svelte 提供了 style: 指令,允许您将 style 属性拆分为多个属性,在添加换行和缩进后,这可能会使代码更易于阅读:
<div
style:color={color}
style:font-size={fontSize}
style:background={background}
style:border-top={borderTop}
/>
style: 指令遵循以下语法:
style:css-property-name={value}
CSS 属性名称可以是任何 CSS 属性,包括 CSS 自定义属性:
<div style:--main-color={color} />
如果样式名称与它所依赖的值名称相匹配,那么您可以使用 style: 指令的简写形式:
<div
style:color
style:font-size={fontSize}
style:background
style:border-top={borderTop}
/>
在 style: 指令中声明的样式具有比 style 属性更高的优先级。在下面的例子中,h1 文本颜色是红色而不是蓝色:
<div style:color="red" style="color: blue;" />
除了逐个为元素添加内联样式来样式化元素外,接下来的方法允许我们编写 CSS 选择器来针对多个元素并将它们一起样式化。
添加 <style> 块
在每个 Svelte 组件中,你可以有一个顶级 <style> 块。
在 <style> 块内,你可以有 CSS 规则,针对组件内的元素:
<div>First div</div>
<div>Second div</div>
<style>
div {
color: blue;
}
</style>
当你想要在组件内的多个元素上应用相同的样式时,这种方法很有用。
在前面的代码中,CSS 规则会将文档中的所有 div 元素都变成蓝色吗?
不。<style> 块内的 CSS 规则的作用域限定在组件内,这意味着 CSS 规则只会应用于组件内的元素,而不会应用于应用中的其他地方的 div 元素。
因此,你不必担心 <style> 块内的 CSS 规则会改变组件外的元素。
但是,这是如何工作的?Svelte 如何确保 CSS 规则只应用于同一组件内的元素?
让我们探索一下。
Svelte 如何作用域组件内的 CSS 规则
让我们稍微偏离一下,来理解 Svelte 如何确保组件内的 CSS 规则是作用域限定的。
当 Svelte 编译一个组件时,Svelte 编译器会遍历每个 CSS 规则,并尝试将每个元素与 CSS 规则的选择器匹配:
<div>row</div>
<style>
div { color: red; }
</style>
当一个元素匹配选择器时,Svelte 编译器将为组件生成一个唯一的 CSS 类名并将其应用于该元素。同时,Svelte 通过在选择器中包含生成的类名的类选择器来限制被 CSS 规则选择的元素的作用域。
元素和 CSS 规则的转换看起来像这样:
<div class="svelte-q5jdbb">row</div>
<style>
div.svelte-q5jdbb { color: red; }
</style>
在这里,"svelte-g5jdbb" 是由计算 CSS 内容的哈希值生成的唯一 CSS 类名。当 CSS 内容发生变化时,哈希值也会不同。由于 Svelte 编译器只将 CSS 类名应用于组件内的元素,因此不太可能将样式应用于组件外的其他元素。
这种转换默认发生在编译过程中。你不需要做任何额外的事情。这里的例子只是为了说明目的。
了解 <style> 块内的 CSS 规则是作用域限定的,当你想要将样式应用于具有相同节点名的所有元素时,使用 CSS 类型选择器作为你的 CSS 规则通常就足够了。然而,如果你只想样式化某些元素,比如在上一个例子中只样式化第二个 div 元素,你可以在第二个 div 元素上添加一个 class 属性来区分其他 div 元素。
是的,添加一个 class 属性是我们样式化 Svelte 组件的另一种方式。
添加 class 属性
CSS 类选择器在 Svelte 中的工作方式与在 CSS 中相同。
当你向元素添加 class 属性时,你可以使用 CSS 类选择器来定位它。
在下面的示例中,我们将 highlight 类添加到了第二个 div 元素上,因此只有第二个 div 元素具有黄色背景:
<div>First div</div>
<div class="highlight">Second div</div>
<style>
.highlight {
background-color: yellow;
}
</style>
class 属性的值可以是字符串或动态表达式。
你可以条件性地将类应用到元素上:
<div class="{toHighlight ? "highlight" : ""} {toBold ? "bold" : ""}" />
在前面的示例中,当 toHighlight 和 toBold 的值都为 true 时,class 属性的值计算为 "highlight bold"。因此,两个类,highlight 和 bold,被应用到 div 元素上。
这种根据变量条件性地将类应用到元素上的模式非常常见,因此 Svelte 提供了 class: 指令来简化它。
使用 class: 指令简化类属性
在前面的示例中,我们当 toHighlight 变量为真值时条件性地应用了 highlight 类,当 toBold 变量为真值时应用了 bold 类。
这可以通过 class: 指令来简化,如下所示:
class:class-name={condition}
使用 class: 指令简化前面的示例,我们得到以下代码:
<div class:highlight={toHighlight} class:bold={toBold} />
就像 style: 属性一样,如果类的名称与条件变量的名称相同,你可以进一步简化为 class: 指令的缩写形式。
如果添加 highlight 类的条件是一个名为 highlight 的变量,那么前面的示例可以重写如下:
<div class:highlight />
将所有这些放在一起,在下面的代码示例中,当 highlight 变量值为 true 时,div 元素具有黄色背景,否则具有透明背景:
<script>
export let highlight = true;
</script>
<div class:highlight />
<style>
.highlight {
background-color: yellow;
}
</style>
我们迄今为止探索的所有将样式应用到元素的方法都涉及在 Svelte 组件内编写 CSS 声明。然而,可以在 Svelte 组件外部定义样式。
从外部 CSS 文件应用样式
假设你向应用程序的 HTML 中添加了一个样式元素,如下所示:
<html>
<head>
<style>.title { color: blue }</style>
</head>
</html>
或者,你可以在应用程序的 HTML 中包含外部 CSS 文件,如下所示:
<html>
<head>
<link rel="stylesheet" href="external.css">
<head>
</html>
在这两种情况下,它们中编写的 CSS 规则被应用到应用程序中的所有元素上,包括 Svelte 组件内的元素。
如果你正在使用构建工具,如 webpack、Rollup 或 Vite 来打包你的应用程序,通常需要配置你的构建工具以使用 import 语句导入 CSS 文件,就像导入任何 JS 文件一样(一些工具,如 Vite,甚至默认配置为允许像导入 JS 文件一样导入 CSS 文件!):
import './style.css';
导入 CSS 模块
在 Vite 中,当你将 CSS 文件命名为以 .module.css 结尾时,该 CSS 文件被视为 CSS 模块 文件。CSS 模块是一个 CSS 文件,其中定义的所有类名都具有局部作用域:
/* filename: style.module.css */
.highlight {
background-color: yellow;
}
这意味着在 CSS 模块中指定的 CSS 类名不会与任何其他地方指定的类名冲突,即使是与相同名称的类名也不会。
这是因为构建工具会将 CSS 模块中的类名转换成独特的东西,这不太可能与任何其他名称冲突。
以下是一个示例,说明前面的 CSS 模块在构建后 CSS 规则将如何呈现:
/* the class name 'highlight' transformed into 'q3tu41d' */
.q3tu41d {
background-color: yellow;
}
当从 JavaScript 模块导入 CSS 模块时,CSS 模块导出一个对象,其中包含原始类名到转换后类名的映射:
import styles from './style.module.css';
styles.highlight; // 'q3tu41d'
在前面的代码片段中,导入的 styles 模块是一个对象,我们可以通过 styles.highlight 获取转换后的类名,即 'q3tu41d'。
这允许你在 Svelte 组件中使用转换后的类名:
<script>
import styles from './style.module.css';
</script>
<div class="{styles.highlight}" />
我们已经看到了六种不同的方式来设置 Svelte 组件的样式,但你是如何选择何时使用哪一种方式的呢?
选择为 Svelte 组件设置样式的技巧:
我们到目前为止看到的每种方法都有其优缺点。大多数时候,选择哪种方法来设置 Svelte 组件的样式取决于个人偏好和便利性。
在选择为我的 Svelte 组件设置样式的技巧时,以下是我的一些个人偏好:
<style> 块与内联样式:
大多数时候,我发现我编写的样式与显示元素的逻辑关系不大,而更多的是关于元素的外观。因此,当样式与元素一起内联时,我发现这会干扰我阅读组件的流程。我更喜欢将所有样式放在 <style> 块的一个地方。
使用 style: 指令和 class: 指令控制样式:
当元素的样式属性依赖于一个变量时,我会使用 style: 指令或 class: 指令而不是 style 属性或 class 属性。我发现这更易于阅读,同时也发现这是一个强烈的信号,告诉我元素的样式是动态的。
当我仅基于一个变量更改一个样式属性时,我会使用 style: 指令。然而,当使用同一个变量更改多个样式属性时,我更倾向于声明一个 CSS 类来将所有样式组合在一起,并通过 class: 指令来控制它。
在多个 Svelte 组件中重用 CSS 声明的 CSS 模块:
在撰写本书时,Svelte 中没有内置方法来在多个 Svelte 组件中共享相同的 CSS 声明。因此,你可能希望通过 CSS 模块来共享 CSS 声明。
然而,更常见的情况是,当你需要在多个 Svelte 组件中为元素使用相同的 CSS 声明时,你可能会遇到一个不太完美的组件结构。可能可以将元素抽象成一个 Svelte 组件。
我们已经看到了如何在 Svelte 组件内部和外部定义自己的样式;然而,有时采用他人编写的样式比设计自己的样式要容易得多。
在下一节中,我们将探讨如何在 Svelte 中使用流行的 CSS 框架 Tailwind CSS。
使用 Tailwind CSS 样式化 Svelte
Tailwind CSS 是一个以实用类为首要的 CSS 框架。它包含预定义的类,如 flex、pt-4 和 text-center,你可以在你的标记中直接使用这些类:
<div class="flex pt-4 text-center" />
我们将使用 Vite 的 Svelte 模板作为基础来设置 Tailwind CSS。如果你不熟悉设置 Vite 的 Svelte 模板,以下是一些快速设置步骤:
运行 Vite 设置工具:
my-project-name containing the basic files necessary for a Svelte project.
进入 my-project-name 文件夹并安装依赖项:
cd my-project-name
npm install
依赖项安装完成后,你可以启动开发服务器:
npm run dev
随着 Svelte 项目运行起来,让我们看看我们需要做什么来设置 Tailwind CSS。
设置 Tailwind CSS
Tailwind CSS 提供了一个 tailwindcss CLI 工具,使得设置变得更加简单。按照以下步骤操作:
要在 Svelte + Vite 项目中设置 Tailwind CSS,我们首先安装所需的依赖项:
@tailwind directive – a Tailwind CSS directive, which you’ll see later.
安装完 tailwindcss 后,运行命令以生成 tailwind.config.js 和 postcss.config.js:
tailwind.config.js file keeps the configuration for Tailwind CSS. Tailwind CSS works by scanning template files for class names and generating the corresponding styles into a CSS file.
在 tailwind.config.js 中指定我们的 Svelte 组件的位置,以便 Tailwind CSS 知道在哪里扫描 Svelte 组件以获取类名:
module.exports = {
content: [
// all files ends with .svelte in the src folder
"./src/**/*.svelte"
],
};
postcss.config.js 文件保存了 PostCSS 的配置。默认生成的配置目前是好的。
在 ./src/main.css 中创建一个 CSS 文件以添加 Tailwind 指令:
@tailwind base;
@tailwind components;
@tailwind utilities;
在 ./src/main.js 文件中导入新创建的 ./src/main.css:
import './src/main.css';
这与之前看到的导入外部 CSS 文件类似。
启动 Vite dev 服务器:
text-center and text-sky-400 Tailwind CSS classes onto the elements in your Svelte component.For example, head over to `./src/App.svelte` and add the following: `<h1 class="text-center` `text-sky-400">Hello World</h1>`
你刚刚创建了一个带有两个 Tailwind CSS 类 <h1> 元素:text-center 和 text-sky-400。这将使 <h1> 元素中的文本居中对齐,并呈现天蓝色。当你使用 Tailwind CSS 实用类名称在你的 Svelte 组件中时,这些类的样式声明会被生成并替换掉 @tailwind utilities 指令。
Tailwind CSS 能够从 class 属性和 class: 指令中提取类名,允许你静态或条件性地将 Tailwind CSS 样式应用到元素上:
<script>
export let condition = false;
</script>
<h1 class="text-center">Center aligned title</h1>
<h1 class:text-center={condition}>
Conditionally center aligned
</h1>
Tailwind CSS 包含了许多实用类:你可以在 tailwindcss.com/ 上了解更多关于 Tailwind CSS 的信息。
由于 Tailwind CSS 类是全局可用的,因此你可以将相同的 CSS 类应用到同一应用程序内的 Svelte 组件上,以保持相同的视觉和感觉。然而,如果你使用自己的样式声明这样做,你可能必须在组件之间重新指定相同的颜色或尺寸以保持相同的视觉。将来更改值也会成为问题,因为我们必须通过值搜索用法并更新它们。
CSS 自定义属性是解决这个问题的方案。它们允许我们在一个地方定义一个值,然后在其他多个地方引用它。让我们看看我们如何在 Svelte 组件中使用 CSS 自定义属性。
使用 CSS 自定义属性为主题化 Svelte 组件
让我们对 CSS 自定义属性进行快速知识检查:
你可以像定义其他 CSS 属性一样定义 CSS 自定义属性,只是 CSS 自定义属性的名称以两个短横线开头:
--text-color: blue;
要引用 CSS 自定义属性,请使用 var() 函数:
color: var(--text-color);
CSS 自定义属性像任何其他 CSS 属性一样具有级联性:
.foo {
--text-color: blue;
}
div {
--text-color: red;
}
<div> 元素的 --text-color CSS 自定义属性的值是红色,除了具有名为 foo 的类的 <div> 元素。
CSS 自定义属性的值从其父元素继承:
<div>
<span />
</div>
如果前一个示例中 <div> 的 --text-color 值是红色,那么如果没有其他 CSS 规则应用到 <span> 上,<span> 的 --text-color 值也是红色。
定义 CSS 自定义属性
要为 Svelte 组件指定一组尺寸和颜色,我们可以在应用程序的根组件中定义它们作为 CSS 自定义属性:
<div>
<ChildComponent />
</div>
<style>
div {
--text-color: #eee;
--background-color: #333;
--text-size: 14px;
}
</style>
在前一个示例中,我们在组件的根元素(<div> 元素)处定义了 CSS 自定义属性。由于 CSS 自定义属性值从父元素继承,子元素和子组件内的元素从 <div> 元素继承值。
由于我们正在定义组件 <div> 元素的 CSS 自定义属性,因此不是 <div> 元素子代元素的元素将无法访问该值。
如果你希望为所有元素定义变量,即使是那些不是根组件根元素的子代元素,你可以在文档的根处使用 :root 选择器来定义 CSS 自定义属性:
<style>
:root {
--text-color: #eee;
--background-color: #333;
--text-size: 14px;
}
</style>
对于 :root,你不需要使用 :global() 伪选择器,因为它始终指向文档的根,并且永远不会作用域到组件。
在 CSS Modules 中,:global() 伪选择器用于定义全局样式,这些样式适用于局部模块作用域之外。在 Svelte 中,当在组件的 <style> 块中使用时,它允许你定义不会作用域到组件的 CSS 规则,使它们在整个 Svelte 应用程序中的元素可用并适用。
作为在<style>块中定义 CSS 自定义属性的替代方案,你可以通过style属性直接在元素本身上定义它们:
<div style="--text-color: #eee; --background-color: #333; --text-size: 14px;">
<ChildComponent />
</div>
如你所回忆,使用style:指令这样做可以使代码看起来更整洁:
<div
style:--text-color="#eee"
style:--background-color="#333"
style:--text-size="14px">
<ChildComponent />
</div>
要在子组件中引用该值,你使用var()函数:
<style>
div {
color: var(--text-color);
}
</style>
使用 CSS 自定义属性的优点在于,我们可以动态地更改 CSS 自定义属性的值,引用该 CSS 自定义属性的元素样式将自动更新。
例如,我可以根据条件指定<div>的--text-color值为#222或#eee:
<script>
export let condition = false;
</script>
<div style:--text-color={condition ? '#222' : '#eee'} >
<ChildComponent />
</div>
当条件为true时,var(--text-color)的值为#222,当条件变为false时,值变为#eee。
如你所见,CSS 自定义属性使得同步元素样式变得容易得多。
现在,让我们看看使用 CSS 自定义属性的实际情况:创建深色/浅色主题模式。
示例 – 实现深色/浅色主题模式
深色模式是一种颜色方案,其中文本颜色较浅,背景颜色较深。深色模式背后的理念是在保持颜色对比度比率的条件下减少屏幕发出的光线,从而使内容仍然可读。来自设备的较少光线使得阅读更加舒适,尤其是在低光环境中。
大多数操作系统允许用户设置是否使用深色或浅色主题的偏好,并且主要浏览器支持prefers-color-scheme媒体查询来指示用户的系统偏好:
@media (prefers-color-scheme: dark) {
/* Dark theme styles go here */
}
@media (prefers-color-scheme: light) {
/* Light theme styles go here */
}
在我们开始之前,让我们确定所需的变量。
为了简化问题,我们只更改背景色和文本色,因此将是--background-color和--text-color。
可能你的应用程序还有其他颜色,例如强调色、阴影色和边框色,这些颜色在深色和浅色主题中需要不同的颜色。
由于这些颜色将被应用到各个地方,我们将在根元素上使用:root伪类来定义它们:
<style>
@media (prefers-color-scheme: dark) {
:root {
--background-color: black;
--text-color: white;
}
}
@media (prefers-color-scheme: light) {
:root {
--background-color: white;
--text-color: black;
}
}
</style>
现在,在我们的 Svelte 组件中,我们需要开始设置文本颜色以使用var(--text-color):
<style>
* {
color: var(--text-color);
}
</style>
就这样;当系统偏好设置为深色主题时,文本颜色将是白色,当设置为浅色主题时,文本颜色将是黑色。
由于 CSS 自定义属性的继承特性,CSS 自定义属性的值将由设置了该值的最近父元素确定。
这为允许组件用户指定组件样式而无需通过更高特异性的 CSS 规则覆盖样式声明打开了大门。
允许用户更改组件的样式
假设你使用 CSS 在<style>块中为组件设置样式,如下所示:
<p>Hello World</p>
<style>
p {
color: red;
}
</style>
如果你想从组件外部修改p元素的色彩,你需要了解以下内容:
使用div :global(p)选择器来覆盖颜色;然而,由于我们不知道组件的实现细节,我们无法确定我们的选择器是否具有更高的特定性:
<div>
<Component />
</div>
<style>
div :global(p) {
color: blue;
}
</style>
组件的元素结构 :
要知道要覆盖哪个元素的色彩,我们需要了解组件的元素结构,以及我们想要更改颜色的文本所在的元素是否是段落元素。
组件的 CSS 规则和元素结构不应成为组件的公共 API 的一部分。通过具有更高特定性的 CSS 规则覆盖组件的样式是不推荐的。对 CSS 规则或元素结构的小幅调整很可能会破坏我们的 CSS 规则覆盖。
一种更好的方法是公开一个 CSS 自定义属性的列表,这些属性可以用来覆盖组件的样式:
<p>Hello World</p>
<style>
p {
color: var(--text-color, red);
}
</style>
var()函数接受一个可选的第二个参数,它是如果第一个参数中的变量名不存在时的后备值。
如果你使用组件而没有定义--text-color,那么段落的颜色将回退到红色。
在以下代码片段中,段落的颜色是红色:
<div>
<Component />
</div>
这里,"display:contents"是为了确保额外的div不参与<Component />内容的布局。
如果我们在使用 CSS 自定义属性时指定后备值,我们可能会发现自己在重复后备值几次。如果我们打算更改后备值,这将变得麻烦。让我们看看我们如何对齐后备值。
对齐后备值
如果我们在多个元素中使用var(--text-color, red),你可能会很快意识到我们也应该定义一个 CSS 自定义属性作为后备值,以免我们重复多次使用该值,将来可能难以找到并替换所有这些值。
要定义另一个 CSS 自定义属性,你必须在组件的根元素上定义它。如果该值仅限于你的组件及其子组件,那么你不应该在文档根元素上通过:root定义 CSS 自定义属性:
<p>Hello World</p>
<style>
p {
--fallback-color: red;
color: var(--text-color, var(--fallback-color));
}
</style>
然而,这种方法要求我们在使用var(--text-color)的任何地方都使用var(--fallback-color)。
一种稍微更好的方法是定义一个新的 CSS 自定义属性,如果已定义,则其值为--text-color,否则为红色作为后备:
<style>
p {
--internal-text-color: var(--text-color, red);
color: var(--internal-text-color);
}
</style>
这样,var(--internal-text-color)的值将始终被定义,并且使用单个 CSS 自定义属性对后续元素进行操作更为方便。
摘要
在本章中,我们介绍了六种不同的方法来样式化 Svelte 组件。那么,你知道你打算使用哪种方法来样式化你的 Svelte 组件吗?你现在应该知道选择最适合场景的方法。
我们首先了解了如何在 Svelte 项目中使用 Tailwind CSS。在开始时,需要一些初始设置来让 Tailwind CSS 启动并运行,但像 Tailwind CSS 这样的 CSS 框架通常自带预定义的 CSS 类,大多数情况下,你使用 class 属性或 class: 指令来应用它们。
最后,我们介绍了如何使用 CSS 自定义属性来为主题 Svelte 组件,以及如何允许组件用户自定义组件的样式。现在,你可以在允许他人使用不同于你创建的默认样式的条件下创建和分享 Svelte 组件。
在下一章中,我们将探讨如何管理 Svelte 组件的 props 和 states。
第三章:管理属性和状态
在网络开发的世界里,有效地管理数据至关重要。无论是组件之间的信息流还是组件的内部状态,适当的数据管理是功能性和响应式网络应用程序的基石。
在本章中,我们将深入研究在 Svelte 应用程序中管理属性和状态的核心概念。首先,我们将明确 Svelte 中的属性和状态是什么,为理解更高级的主题打下基础。然后,我们将探讨绑定的概念,这是 Svelte 中用于保持状态和元素值或组件属性同步的功能。
然后,我们将探讨组件内的数据流,突出单向数据流和双向数据流之间的差异以及它们为什么重要。接下来,我们将讨论如何使用 Svelte 的响应式声明从属性派生状态。最后,我们将提供管理复杂派生状态的技巧,并解释如何根据这些派生状态更新属性。
到本章结束时,你将牢固地理解如何在 Svelte 组件中管理数据,并掌握实用的技巧和策略来有效地应对常见挑战。
在本章中,你将学习以下内容:
定义属性和状态
理解绑定
单向与双向数据流
使用响应式声明从属性派生状态
管理复杂的派生状态
使用派生状态更新属性
在我们开始讨论属性和状态之前,让我们首先定义在 Svelte 中属性和状态是什么。
技术要求
你可以在 GitHub 上找到本章中使用的所有代码示例:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter03
定义属性和状态
在 Svelte 中,属性和状态都用于在组件内管理数据。属性是一种将数据从父组件传递到子组件的方式。这使得子组件更加灵活和可重用,因为它可以根据需要从父组件获取不同的数据。
另一方面,状态是在组件内部初始化和管理的内部数据,与从外部来源接收的属性不同。状态允许组件自包含和模块化。
定义属性
让我们从属性开始。在 Svelte 中,属性使用 export 关键字定义。当你在一个 Svelte 组件中导出一个变量时,它就变成了一个可以传递数据的属性,你可以从父组件传递数据。
这里有一个简单的例子:
<!-- file: Child.svelte -->
<script>
export let message;
</script>
<h1>{message}</h1>
在前面的代码片段中,我们在名为 Child.svelte 的文件中定义了一个 Svelte 组件。在 Svelte 组件中,message 是一个属性。你可以从父组件传递数据到 message,如下所示:
<!-- file: Parent.svelte -->
<script>
import Child from './Child.svelte';
</script>
<Child message="Hello, World!" />
在前面的代码片段中,我们在名为 Parent.svelte 的文件中定义了另一个 Svelte 组件。在这个组件中,我们导入并使用来自 Child.svelte 的 Child 组件。由于 Parent 组件包含了 Child 组件,因此 Parent 组件被认为是导入的 Child 组件的父组件。
在父组件中,你可以通过 <Child /> 组件的 message 属性传递 "Hello, World!" 来设置子组件的 message 道具,如前面的代码片段所示。
总结来说,道具是通过 export 关键字定义的,它们的值是从父组件传递到子组件的。
定义状态
接下来,让我们看看状态。状态是任何在组件内部使用和管理的数据。它不像道具那样从父组件传递进来。相反,它是在组件内部定义的。
这里有一个说明状态的例子:
<!-- file: Counter.svelte -->
<script>
let count = 0;
function increment() {
count += 1;
}
</script>
<button on:click={increment}>Click me</button>
<p>{count}</p>
在这个例子中,count 是一个状态变量。它不是作为道具传递的,而是在 Counter 组件内部定义和管理的。当你点击按钮时,会调用 increment 函数,该函数修改 count 状态。
总结来说,道具是从父组件传递到组件的变量,而状态是组件内部管理的数据。
道具与状态
如果你仔细观察,道具和状态都代表数据。它们之间的区别取决于你考虑的组件的上下文。
例如,让我们考虑两个组件,组件 A 和组件 B。
让我们从组件 A 开始:
<!-- A.svelte -->
<script>
export let height;
</script>
在组件 A 中,我们定义了一个名为 height 的道具。
现在让我们看看组件 B:
<!-- B.svelte -->
<script>
import A from './A.svelte';
let height = 0;
</script>
<A height={height} />
在组件 B 中,我们定义了一个名为 height 的状态,并将其值作为道具传递给组件 A。
从组件 B 的角度来看,height 被视为状态,但从组件 A 的角度来看,它被视为道具。一个变量是道具还是状态取决于它被查看的组件的上下文。在本质上,它们基本上是同一件事。
在这个例子中,由于 JavaScript 以值的方式传递原始变量,可能不会立即明显地看出组件 A 和组件 B 中的 height 变量指向同一件事。
然而,如果我们定义一个对象作为状态并通过道具传递给另一个组件,那么就会变得明显,一个组件中的状态和另一个组件中的道具都指向同一个对象引用。
让我们修改我们的例子来说明这一点:
<!-- A.svelte -->
<script>
export let height;
setInterval(() => console.log('A:', height), 1000);
</script>
<!-- B.svelte -->
<script>
import A from './A.svelte';
let height = { value: 100 };
setInterval(() => console.log('B:', height), 1000);
</script>
<A height={height} />
在这个代码片段中,我添加了一个 setInterval 函数,每秒钟在两个组件中打印出 height 变量的值。在组件 B 中,我将状态 height 修改为对象。因为 JavaScript 中的对象是通过引用传递的,所以作为属性传递给组件 A 的组件 B 中的状态 height 也是通过引用传递的。这意味着组件 A 中的 height 属性引用的是与组件 B 中的状态 height 相同的对象。
如果我们在组件 B 中添加一个 <button> 元素来修改 height 对象,如下所示,你将能够看到组件 A 和 B 都会在控制台中打印出相同的更新后的 height 变量值。这是因为它们打印的是相同对象引用的值:
<button on:click={() => { height.value += 10; }} />
点击前面代码片段中的按钮会导致控制台打印出 A: { value: 110 } 和 B: { value: 110 }。这表明组件 A 和 B 中的 height 变量引用的是相同的对象引用。当组件 B 中的 height.value 更改为 110 时,这个变化也会反映在组件 A 中的 height 变量上。
现在我们已经了解了 Svelte 中的 props 和状态是什么,接下来让我们谈谈绑定。
理解绑定
Svelte 中的绑定允许你保持组件状态值与 <input /> 元素值的同步。如果状态发生变化,输入会更新;反之,如果输入发生变化,状态也会更新。
以下代码片段是创建 Svelte 中绑定的一个示例:
<script>
let name = "John";
</script>
<input bind:value={name} />
绑定是通过 bind: 指令创建的。在前面代码片段中,输入元素的值被绑定到 name 变量。当你输入时,name 变量将自动更新。反之,当你更改 name 变量的值时,输入元素的值也会自动更新。
如所示,绑定创建了一个双向数据流,使得数据变化可以从元素传播到组件状态,以及从组件状态传播到元素。
之前的例子演示了在元素上的绑定,但绑定也可以在组件上工作。你可以使用 bind: 指令将组件的属性与你的组件状态链接起来,如下面的代码所示:
<script>
import Profile from './Profile.svelte';
let name = "John";
</script>
<Profile bind:username={name} />
在前面的代码片段中,我们将 <Profile> 组件的 username 属性绑定到 name 状态变量。当你更新 name 状态变量时,username 属性的值将自动反映新的值;反之,如果你在 <Profile> 组件内部更新 username 属性的值,name 状态变量的值也会自动更新以匹配。
为了进一步演示这种行为,让我们对代码进行一些小的修改。这是组件的更新版本:
<script>
import Profile from './Profile.svelte';
let name = "John";
</script>
<p>Name from App: {name}</p>
<Profile bind:username={name} />
<p> element and a <button> element. The <p> element shows the value of the name state variable, and the <button> element, when clicked, updates the value of the name state variable to Svelte. Due to the binding, when the button is clicked, it will also update the username props in the <Profile> component.
Here’s the updated version of the `<``Profile>` component:
Profile 中的名称:{username}
<button on:click={() => username = "World"}>从个人资料更新
In this code snippet, we are looking at the `<Profile>` component. This component receives a prop called `username`, whose value is displayed inside a `<p>` element. We’ve also added a button, and when clicked, it will update the value of the `username` prop to `"World"`.
Because of the binding we established in the parent component, any change to the `username` prop in this `<Profile>` component will also update the `name` state variable in the parent component.
Indeed, if you click on the button in the parent component, both the `name` state variable in the parent component and the `username` prop in the `<Profile>` component will update to `"Svelte"`. On the other hand, if you click on the button in the `<Profile>` component, both the `name` state variable in the parent component and the `username` prop in the `<Profile>` component will change to `"World"`. This is the power of two-way binding in Svelte, allowing you to easily synchronize data between parent and child components.
When we talk about binding in Svelte, we often refer to *two-way data binding*. But what exactly does *two-way* mean? And is there such a thing as *one-way* data binding? Let’s delve deeper into the concept of data flow to clarify these terms.
One-way versus two-way data flow
When you pass data from one component to another component either through props or binding, data flows from one component to another component. The term data flow refers to how data is passed or transmitted between components or elements within a web application.
Understanding data flow is important when designing an application’s architecture, as it helps to establish clear lines of communication between components and determine how information is shared and updated throughout the system.
Data flow can be unidirectional (one-way) or bidirectional (two-way), depending on how data is transferred between components.
In one-way data flow, data moves in a single direction, from a parent to a child component, or from a component to a DOM element. This unidirectional flow is achieved through component props or DOM element attributes.
For example, consider the following code snippets featuring two components, component `A` and component `B`.
In component `B`, we define a prop named `value`:
In component `A`, we import component `B` and pass the value of a variable named `data` to the prop of component `B`:
Here, the value of `data` in component `A` is passed to the `value` prop in component `B`, illustrating the data flow from component `A` to component `B`.
If you draw up the data flow in a diagram, this is what a one-way data flow looks like:

Figure 3.1: One-way data flow
The boxes show the data state and value prop, and the arrow shows how changing one value will affect another.
In one-way data flow, changes to data in the parent component automatically propagate to the child component, but the reverse is not true.
Building on the example with component `A` and component `B`, if the value of the `data` variable in component `A` changes, this change would automatically update the `value` props in component `B`. However, any changes made directly to the `value` props in component `B` would not affect the `data` variable in component `A`. Data changes only flow from component `A` to component `B`, but not in the reverse direction.
Having a one-way data flow makes the application easier to reason about and debug, as data changes follow a predictable path. For example, when the `data` variable in component `A` changes unexpectedly, isolating the issue becomes more straightforward. Because of the one-way data flow, we know that any changes to the `data` variable in component `A` will only originate from component `A`, not from component `B`.
On the other hand, two-way data flow allows data to flow in both directions, enabling automatic updates between the parent and child components, or between a component and DOM elements. Two-way data flow in Svelte is achieved through component or DOM element bindings.
For example, consider the following code snippets featuring two components, component `C` and component `D`.
In component `D`, we define a prop named `value`:
In component `C`, we import component `D` and bind the value of a variable named `data` to the prop of component `D`:
Here, the value of `data` in component `C` is bound to the `value` prop in component `D`, illustrating the two-way data flow between component `C` to component `D`.
Here is a diagram showing how the data flows in a two-way data flow:

Figure 3.2: Two-way data flow
In a two-way data flow, when you change the data in the parent component, the child component is automatically updated; conversely, changing the data in the child component automatically updates the parent.
Building on the example with component `C` and component `D`, if the value of the `data` variable in component `C` changes, this change would automatically update the `value` props in component `D`. Conversely, unlike one-way data flow, any changes made directly to the `value` props in component `D` would update the `data` variable in component `C`. Data changes flow from component `C` to component `D` as well as from component `D` to component `C`.
While this bidirectional flow of data can make it easier to keep data in different components synchronized, it can also introduce complexity and make the application harder to debug, since data changes can originate from multiple sources.
For example, when the `data` variable in component `C` changes unexpectedly, isolating the issue becomes more complex. Because of two-way data flow, any changes to the `data` variable in component `C` could originate from component `C`, as well as from component `D`. This complexity can escalate further if the `value` prop of component `D` is also bound to the prop of another component.
In summary, one-way data flow offers predictability and easier debugging, while two-way data flow makes it much easier to synchronize data across multiple components but at the cost of added complexity.
Now that we’ve delved into both one-way and two-way data flows via props, let’s explore how to create state variables that derive their values from props.
Deriving states from props with a reactive declaration
It’s common in Svelte to create new state variables based on the values of props.
For instance, a `<DateLabel />` component might accept a `date` value as a prop and display a formatted date inside a `<label>` element. To use the `<DateLabel>` component, you might write the following:
<DateLabel date={new Date(2023,5,5)} />
To display the date as formatted text, you could first define a variable named `label`, deriving its value from the `date` prop:
{label}
In this code snippet, we defined a variable called `label` and derived its value from the `date` prop using the `toLocaleDateString()` method. This variable is then used inside a `<label>` element to display the formatted date.
In the preceding code snippet, the `label` variable is initialized when the `<DateLabel>` component is first created. However, if the `date` prop changes after the component has been initialized, the `label` variable won’t update to reflect the new value. This is not the intended behavior, as the `<DateLabel>` component should always display the most up-to-date formatted date based on the latest `date` prop.
To solve this issue, you can use Svelte’s reactive declarations to keep the `label` variable updated whenever the `date` prop changes.
Svelte’s reactive declarations utilize a special `$:` syntax to mark a statement as reactive. This means that whenever the variables used in that statement change, the statement itself will be re-run.
Let’s modify our component code to use Svelte’s reactive declaration:
In this code snippet, by modifying the declaration of `label` to a reactive declaration, the component will automatically re-compute the `label` variable whenever the `date` prop is modified, ensuring that the `<DateLabel>` component is always displaying the most current formatted date.
So, how does Svelte know when a reactive declaration statement should be re-run?
Svelte re-runs a reactive declaration statement whenever any of its dependent variables change. The Svelte compiler identifies these dependencies by analyzing the statement.
For example, in the reactive declaration `$: label = date.toLocaleDateString();`, Svelte recognizes that the dependency for this statement is the `date` variable. Therefore, whenever the `date` changes, the statement will re-run and update the value of the `label` variable.
A good rule of thumb for identifying dependencies in a reactive declaration is to look for any variable on the right side of the equal sign (`=`). These variables are considered dependencies of the reactive declaration.
You can include multiple dependencies within a single reactive declaration. For instance, let’s say we want to add a new `locale` prop to the `<DateLabel>` component. To use this new prop, you might write something like this:
<DateLabel date={new Date(2023, 5, 5)} locale="de-DE" />
In this code snippet, we pass in a new `locale` prop with the value `de-DE` to format the date in German. To accommodate this new `locale` prop, we’ll need to modify our `<DateLabel>` component as follows:
{label}
In this updated code, the reactive declaration `$: label = date.toLocaleDateString(locale);` now has two dependencies: `date` and `locale`. Svelte will automatically re-run this statement whenever either of these variables changes, ensuring that the `label` value stays up to date with the latest `date` and `locale` props.
Now that we’ve covered the basics of props, bindings, states, and derived states, it’s crucial to note that as components become more complex, managing these elements can quickly become overwhelming. In the next section, we’ll explore some tips for effectively managing complex derived states to keep them manageable.
Managing complex derived states
As your Svelte application grows more complex, it will likely involve a greater number of interconnected components with multiple props and derived states. When dealing with this complexity, tracking updates and changes can become a complex task. Each prop or state change can affect other parts of your component, making it challenging to manage and predict how your component will behave.
To make this easier, here are some guidelines to consider:
* *Maintain one-way data flow for* *derived states*
While it’s possible to derive state from props and other states, it’s crucial to maintain a one-way data flow to simplify both debugging and understanding. Consider the following Svelte example:
```
<script>
export let valueA;
export let valueB;
$: valueC = valueA + 5;
$: valueD = valueB + valueC;
$: valueC = Math.min(valueC, valueD / 2);
</script>
在观察数据流时,我们可以看到 valueC 依赖于 valueA,valueD 依赖于 valueB 和 valueC,然后 valueC 又反过来依赖于 valueD。因此,valueC 的实际计算方式并不明确,它可能是 valueA + 5 或 valueC 和 valueD / 2 中的最小值。这种复杂性使得代码难以理解,并增加了出现错误的可能性。
```js
* *Group similar* *logic together*
Consider this tip a stylistic suggestion. When dealing with multiple reactive declarations, it’s beneficial to group those that are related together. Utilize blank lines to create a visual separation between unrelated reactive declarations. This not only improves readability but also aids in code maintenance.
It is worth noting that the Svelte compiler takes care of execution order based on dependencies regardless of how you arrange your declarations. For example, the following two code snippets will behave identically due to Svelte’s handling of dependencies:
```
<script>
export let a;
$: b = a * 2;
$: c = b * 2;
</script>
b 在 c 声明之前被声明为响应式。然而,它的行为与以下片段相同,其中 c 在 b 声明之前。让我们看看下一个:
```js
```
<script>
导出 let a;
$: c = b * 2;
$: b = a * 2;
</script>
```js
The Svelte compiler analyzes the dependencies of each declaration and executes them in the correct order. In this case, it evaluates `b = a * 2` before `c = b * 2` since the latter depends on the value of `b`, established by the former declaration.
* *Avoid reassigning* *props directly*
It might be tempting to modify the value of a prop directly, especially when you want to transform its value or provide a default fallback. For example, you might consider writing something like this to set a default value for an undefined prop:
```
<script>
export let data;
$: data = data ?? 100;
</script>
相反,最好声明一个新的状态变量来管理这种行为:
```js
```
<script>
export let data;
$: dataWithDefault = data ?? 100;
</script>
```js
In this improved example, we introduce a new variable, `dataWithDefault`, which takes on either the value of the `data` prop or a default value of `100` if `data` is undefined. This approach makes the component’s behavior more transparent and easier to debug.
* *Be cautious when updating* *derived states*
Modifying a derived state directly can introduce inconsistencies, particularly when that state is based on props or other state variables.
Consider the following example:
```
<script>
export let value;
$: double = value * 2;
$: triple = value * 3;
</script>
<input bind:value={double} type="number" />
<input bind:value={triple} type="number" />
```js
In this example, we have a single prop named `value` and two derived states, `double` and `triple`, which are two and three times the value of the `value` prop respectively. The component features two input boxes, each bound to `double` and `triple` using two-way binding.
Here, `triple` can be modified in two ways: either by updating the `value` prop, which will keep `triple` at three times the value of `value`, or by directly changing the value in the input box, thereby directly altering `triple`.
If you type into the input box bound to `triple`, you’ll find that its value diverges from being strictly three times the `value` prop. This inconsistency arises because `triple` is now subject to changes from multiple sources, causing it to go *out of sync* with the original `value`.
If you were to map out a diagram illustrating the data flow between `value` `double` and `triple`, you’d get the following diagram:

Figure 3.3: Data flows between props, states, and the input elements
Notice that both `double` and `triple` have two incoming arrows, suggesting multiple pathways for changing their values. Conversely, `value` has no incoming arrows, indicating that modifying `triple` alone would cause `value` and `double` to go out of sync.
Therefore, it is recommended to refrain from manually updating derived states, as this complicates debugging and makes the component’s behavior harder to reason about.
If you wish to modify the input while maintaining the synchrony between `value`, `double`, and `triple`, a solution will be discussed in the following section.
By keeping these tips in mind, you can better manage complex derived states and make your components more maintainable and easier to understand.
In our last tip, we highlighted that updating the derived states can lead to inconsistencies between states and props and noted that there’s a solution to modify the input while keeping everything in sync. That solution is what we will explore next.
Updating props using derived states
In an attempt to synchronize the `value` prop with changes to the input bound to `triple`, one might be tempted to add another reactive declaration. This declaration would update the `value` prop to be one-third of `triple` whenever `triple` changes. Here is the proposed modification:
As we discussed earlier, it’s best practice to maintain a one-way data flow for derived states to simplify debugging and data management. Indeed, the Svelte compiler flags the preceding code snippet for cyclical dependencies. This is because `double` is derived from `value`, and `value` is in turn dependent on `double`.
However, Svelte’s compiler determines dependency relationships based solely on the reactive declarations. By refactoring the code to make these relationships less obvious, you can bypass the compiler’s cyclical dependency check. Here’s a refactored version of the code that does just that:
In the provided code snippet, we’ve shifted the equations `value = double / 2` and `value = triple / 3` into separate functions named `updateValueFromDouble` and `updateValueFromTriple`. This change lets us evade Svelte’s compiler warning about cyclical dependencies.
However, there’s a catch. If you try altering the `triple` input, it updates `value` but doesn’t refresh `double`. This happens because Svelte avoids infinite update loops. Changing `triple` would set off a chain reaction—updating `value`, then `double`, then back to `value`, and so on.
This is how the data flow looks right now:

Figure 3.4: Data flows between props, states, and the input elements
As you can see in the diagram, we update the value of `value`, `double`, and `triple` through reactive declarations, creating a loop in the data flow, indicated by the bold arrows.
Therefore, using derived states to update their original properties via reactive declarations isn’t advisable.
A better approach to keep `value`, `double`, and `triple` in sync is to establish value as the single source of truth. Since both `double` and `triple` are derived from `value`, any changes to the input should first update `value`. This, in turn, triggers the reactive declarations to automatically recalculate `double` and `triple`.
Here’s the updated code:
<input value={double} type="number" on:change={e => updateValueFromDouble(e.target.value)} />
<input value as the sole source of truth. Instead of binding the input elements directly to double and triple, we’ve added event listeners that update value based on user input. This change automatically updates double and triple through reactive declarations, which then refresh the displayed values in the input fields.
通过更新的代码,数据流现在已简化,如下面的图所示:
图 3.5:props、states 和输入元素之间的数据流
double和triple的值直接从value派生,然后填充输入字段。当你修改输入时,它会直接改变value,进而自动更新double和triple,以及输入字段本身。
因此,这是通过关注数据流并保持单一事实来源来维护value、double和triple同步状态的方法。
摘要
理解如何有效地处理 props 和 state 对于创建健壮的 Svelte 应用至关重要。本章向您展示了 Svelte 如何使用 props、bindings 和 reactive declarations 来促进组件间的数据传递和状态变化。
在组件内部关注数据流至关重要。拥有统一和有序的数据流使得代码更容易跟踪和调试。良好的数据管理为轻松构建更复杂和动态的应用铺平了道路。
在下一章中,我们将探讨如何组合 Svelte 组件来构建更复杂的应用。
第四章:组合组件
随着您的应用程序的增长,将所有逻辑都塞入单个组件变得不切实际。您需要将应用程序拆分为更小的、模块化的组件,并将它们组装成更复杂的应用程序。
在本章中,我们将探索各种有效组合组件的技术。我们将首先检查如何使用插槽向组件注入自定义内容。然后,我们将讨论如何在组件内条件性地渲染不同的 HTML 元素。我们还将深入研究递归组件,这对于显示嵌套或分层数据非常有用。
我们将通过实际示例引导您了解每个主题,确保您学习的技巧既实用又适用于现实世界的场景。到本章结束时,您将拥有一套更丰富的策略来构建您的 Svelte 应用程序中的组件。
在本章中,您将学习以下内容:
从父组件的角度操纵子组件的外观
通过插槽传递动态内容
渲染不同的 HTML 元素和组件类型
为递归数据创建递归组件
容器/表现模式
让我们从探索我们可以操纵和控制子组件内容的各种方式开始。
技术要求
本章中所有的代码都可以在github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter04 找到。
操纵子组件的外观
当您组合多个组件时,您需要管理每个子组件的外观和行为。尽管子组件处理自己的显示和逻辑,但它仍然提供了一些控件来调整其外观和行为。从父组件的角度来看,您将想要协调这些子组件以实现所需的总体功能。
在本节中,我们将探索控制子组件外观的各种方法,从最常用到最不常用的方法。了解这些选项将使您拥有制作既灵活又有效的组件的工具。
控制子组件外观的选项列表包括以下内容:
通过 props 控制 :这可能是在子组件的行为和外观上产生影响的最直接方式。通过从父组件传递 props 到子组件,您可以使子组件高度动态并对外部变化做出响应。
为了演示父组件如何使用 props 在 Svelte 中控制内容,请考虑以下代码示例:
<script>
import Child from './Child.svelte';
let message = 'Hello from Parent';
</script>
<Child message={message} />
Child component and passed the message prop to it. The child component, implemented in the following code snippet, then uses this message prop to display text within a <p> element:
<script>
export let message;
</script>
<p>{message}</p>
正如你所见,父组件可以通过修改message属性值的值来指定Child组件中显示的文本。控制属性是一种简单而有效的方式来操纵子组件的内容。如果你对属性感兴趣,我们在第三章 中对此进行了广泛讨论。
parent组件:
<script>
import Child from './Child.svelte';
import { setContext } from 'svelte';
setContext('message', 'Hello from parent');
</script>
<Child />
在之前的代码中,我们使用setContext创建了一个名为message的上下文,其值为'Hello from parent',然后导入并使用了Child组件,而没有向其发送任何属性。
以下是为Child组件的代码:
<script>
import { getContext } from 'svelte';
const message = getContext('message');
</script>
<p>{message}</p>
在这里,使用getContext读取message上下文值,然后在一个<p>元素中显示。如所示,父组件可以通过更改上下文值来影响子组件的<p>元素中的文本。
若要深入了解 Svelte 的上下文功能,你可以参考第八章 ,我们将更详细地探讨这个主题。
控制样式 :操纵子组件的外观并不仅仅是控制传递给它的数据。它还涉及到调整或修改其样式。
你可以通过 CSS 自定义属性来修改组件样式。这种方法提供了更大的设计灵活性,并确保子组件能够适应父组件或更广泛应用程序中的各种上下文或主题。
若要深入了解如何使用 CSS 自定义属性来更改组件样式,请参阅第二章 。
通过插槽传递动态内容 :Svelte 的插槽功能允许你将自定义内容插入子组件的特定区域。这提供了一种灵活的方式来更好地控制组件的内容,而无需修改其内部行为。
在下一节中,我们将讨论插槽及其使用方法。
正如你所见,有各种方式来塑造子组件的外观和行为。当我们在一个父组件内组合不同的组件时,目标是使它们以协调的方式协同工作。你可以使用所讨论的方法的组合来实现这一点。
大多数这些方法将在单独的章节中介绍,在下一节中,我们将关注如何通过传递内容通过插槽来动态改变子组件的外观。
通过插槽传递动态内容
在构建复杂应用程序时,一种大小并不总是适合所有情况。在组件的模块化和定制灵活性之间取得平衡是至关重要的。
以一个通用的Card组件为例。有时你可能希望为特定用例包含特殊的标题、独特的列表或自定义页脚。几乎不可能预见所有需求,因此设计既模块化又可维护的组件,同时仍然允许定制,是至关重要的。
这正是 Svelte 槽位功能大放异彩的地方。Card 组件旨在包含所有可能的功能,目标是简单、干净的基座,可以通过组合进行增强。这种方法允许您根据需求的变化,拼凑出更复杂、定制的组件。
在 Svelte 组件中,一个 <slot> 元素是组件内的一个占位符,您可以在其中注入来自父组件的任何类型的内容。以下是您如何在 Svelte 组件中定义槽位的方法:
<!-- filename: Card.svelte -->
<div class="card">
<slot></slot>
</div>
在前面的代码片段中,我们在 <div> 元素内部定义了一个 <slot> 元素。任何来自父组件的动态内容都将插入到 <div> 元素中。
现在,要将父组件中的动态内容传递到子组件的槽位中,您需要将该内容放置在子组件的开闭标签之间:
<script>
import Card from './Card.svelte';
</script>
<Card>
<h1>Special headline</h1>
</Card>
在此代码片段中,我们在 <Card> 组件的开闭标签之间传递了一个 <h1> 元素。这实际上用我们提供的 <h1> 元素替换了 Card 组件中的 <slot> 元素。
可能存在不需要从父组件将动态内容传递到子组件槽位的情况。对于这些情况,Svelte 允许您在 <slot> 元素内定义默认内容,当父组件没有提供自定义内容时提供回退选项。
提供默认槽位内容
要为您的 <slot> 元素提供默认内容,您可以将它们放置在 <slot> 元素内部,如下所示:
<div class="card">
<slot>
<div>this is the default content</div>
</slot>
</div>
在前面的代码片段中,我们定义了 <div>,其中包含 <slot> 元素内的文本 This is the default content。这作为槽位的默认内容。
当您在父组件中使用此 Card 组件且不提供槽位内容时,如以下代码片段所示,默认文本将自动出现:
<script>
import Card from './Card.svelte';
</script>
<Card />
通过利用槽位的默认内容,您可以创建更灵活的 Svelte 组件,当没有提供自定义内容时,提供合理的回退选项。
在我们迄今为止看到的示例中,我们被限制为仅将来自父组件的动态内容插入到一个位置。但如果我们想要多个动态内容插入点怎么办?让我们探索如何实现这一点。
有多个带有命名槽位的槽位
槽位非常出色,您也不限于单个槽位。您可以在组件中定义多个槽位并为它们命名,以针对特定的动态内容区域。
您可以使用 <slot> 元素上的 name 属性来为槽位命名,如下面的代码所示:
<!-- filename: Card.svelte -->
<div class="card">
<header>
<slot name="header"></slot>
</header>
<footer>
<slot name="footer"></slot>
</footer>
</div>
在前面的代码片段中,我们定义了两个命名槽位,一个命名为 "header",另一个命名为 "footer"。
要将动态内容针对这些命名槽位,您需要在父组件中使用 <svelte:fragment> 元素:
<script>
import Card from './Card.svelte';
</script>
<Card>
<svelte:fragment slot="header">
<h1>Special headline</h1>
</svelte:fragment>
<svelte:fragment slot="footer">
Custom footer
</svelte:fragment>
</Card>
在这里,我们向 Card 组件传递了两个 <svelte:fragment> 元素。第一个具有槽位属性设置为 "header",第二个槽位属性设置为 "footer"。
在slot属性中指定的名称将与Card组件内的命名插槽相对应。在<svelte:fragment slot="header">内的内容替换了Card组件中的<slot name="header">。同样,在<svelte:fragment slot="footer">内的内容取代了组件中的<slot name="footer">。
有时,你可能希望父组件的动态内容了解子组件中的数据。为了处理这种情况,Svelte 提供了一个称为插槽属性的功能,允许你从子组件将数据传递到插槽中的内容。让我们看看它是如何工作的。
通过插槽属性传递数据
你可以使用插槽属性,如下所示,从子组件将数据传递回父组件中的动态内容:
<!-- filename: Card.svelte -->
<div class="card">
<header>
<slot name="header" width={30} height={50}></slot>
</header>
</div>
在代码示例中,我们除了传递了用于命名插槽的name属性外,还向<slot>元素传递了两个额外的属性。这些额外的属性,width和height,作为插槽属性;它们的值可以在创建动态内容时在父组件中访问。
这里有一个示例,说明你如何在使用<svelte:fragment>时使用这些插槽属性值:
<script>
import Card from './Card.svelte';
</script>
<Card>
<svelte:fragment slot="header" let:width let:height>
<h1>Dimension: {width} x {height}</h1>
</svelte:fragment>
</Card>
在前面的代码片段中,你可以看到我们在<svelte:fragment>元素内部使用了let:width和let:height。这些被称为let:绑定。
let:绑定使我们能够访问由子Card组件提供的width和height插槽属性。然后,这些宽度和高度的值在<h1>元素中使用,以显示尺寸。这样,我们可以根据来自子组件的数据动态调整父组件中的内容。
现在我们已经涵盖了如何通过插槽传递动态内容、如何定义多个命名的插槽以及如何将子组件中的数据传递给父组件中的动态插槽内容,你现在已经具备了创建更灵活和可重用 Svelte 组件的能力。
无渲染组件
使用插槽组合组件的许多常见模式之一是无渲染组件模式。这种技术允许你构建纯函数组件,将展示细节留给父组件。我们专门在第十一章 中深入探讨了这一迷人的主题。
现在我们已经探讨了如何通过插槽传递动态内容,接下来让我们深入了解 Svelte 的另一个令人兴奋的特性,它允许你动态渲染不同的元素或组件类型。
渲染不同的 HTML 元素或组件类型
在任何动态应用程序中,都会有一个时刻你需要比静态组件或元素提供的更多灵活性。假设你在运行时才知道需要渲染的元素或组件的类型呢?
让我们假设您正在构建一个表单生成器,表单字段的类型——无论是 <Input>、<Checkbox> 还是 <Select>——由动态数据决定。您如何在这些组件之间无缝切换,尤其是当它们共享同一组属性时?
一个简单的方法是使用 Svelte 的 {#if} 块有条件地渲染所需的组件。以下是一个示例代码片段:
<script>
import Input from './Input.svelte';
import Checkbox from './Checkbox.svelte';
import Select from './Select.svelte';
let type = "input"; // Could be "checkbox" or "select"
</script>
{#if type === "input"}
<Input value={value} onChange={onChange} />
{:else if type === "checkbox"}
<Checkbox value={value} onChange={onChange} />
{:else}
<Select value={value} onChange={onChange} />
{/if}
在代码片段中,我们使用 {#if} 块来选择三种不同的组件类型,并将相同的属性值传递给每个组件。如果您发现自己需要管理更多组件类型,这可能导致一系列冗长且难以维护的条件块。
有没有更有效的方法来处理这个问题?
Svelte 提供了两个专用元素,<svelte:element> 和 <svelte:component>,正是为了这个目的。<svelte:element> 元素允许您根据变量数据动态创建不同的 HTML 元素类型,而 <svelte:component> 元素允许您动态渲染不同的 Svelte 组件类型。
这里是如何使用 <svelte:component> 重新编写上一个示例的:
<script>
import Input from './Input.svelte';
import Checkbox from './Checkbox.svelte';
import Select from './Select.svelte';
let type = "input"; // Could be "checkbox" or "select"
let DynamicComponent;
if (type === "input") {
DynamicComponent = Input;
} else if (type === "checkbox") {
DynamicComponent = Checkbox;
} else {
DynamicComponent = Select;
}
</script>
<svelte:component
this={DynamicComponent}
value={value}
onChange={onChange}
DynamicComponent variable is used to hold the type of component that will be rendered. This component type is then passed to the this attribute within the <svelte:component> element. The <svelte:component> element also accepts other props such as value and onChange.
With the preceding code, what happens is that `<svelte:component>` renders the designated component stored in the `this` attribute, simultaneously forwarding any props passed to `<svelte:component>` to this dynamically rendered component. For example, if the value of `DynamicComponent` is the `Select` component, then the preceding code is effectively the same as this code:
By using `<svelte:component>`, we simplify the code and make it more maintainable. It’s also easier to extend; adding another form element type would only require an additional condition and assignment.
Now that we’ve explored the use of `<svelte:component>`, let’s look at `<svelte:element>`, which follows a similar pattern. The following is a sample code snippet that demonstrates the usage of `<svelte:element>`:
<svelte:element this={type}>
点击我
</svelte:element>
In the preceding code snippet, the `type` variable holds the type of HTML element we want to render – in this example, it’s `button`. The `<svelte:element>` tag dynamically creates an HTML element of the type specified by `type`. So, if `type` is `'button'`, this will render as a `<button>` element, containing the text `"``Click Me"`.
This approach is particularly useful when you want to switch the type of an HTML element, based on some condition, without having to rewrite the entire block of code. All you need to do is change the value of `type`.
To summarize, `<svelte:element>` and `<svelte:component>` offer a more efficient and maintainable way to handle dynamic rendering needs. They provide a robust alternative to multiple `{#if}` blocks, making your Svelte applications more flexible and easier to manage.
Sometimes, when designing Svelte components for data visualization, we encounter recursive data structures. In such cases, we need to build components that can recursively render themselves. Let’s delve into how we can accomplish this next.
Creating recursive components for recursive data
A **recursive data structure** is a data structure that is defined in terms of itself, meaning that it can be composed of smaller instances of the same type of structure. Recursive data is everywhere – think of a comment section where replies can have their own sub-replies, or a filesystem with nested folders. Creating a component to display them in a frontend application can be challenging.
Imagine we have a variable called `folder`, which is an array containing either files or additional folders. In this example, the `folder` variable could look like this:
const folder = [
{ type: 'file', name: 'a.js' },
{ type: 'file', name: 'b.js' },
{ type: 'folder', name: 'c', children: [
{ type: 'file', name: 'd.js' },
]},
];
Currently, our `folder` variable is two levels deep. To represent this structure in a Svelte component, you might think to use nested `{#``each}` blocks:
{#each folder as item}
{#if item.type === 'file'}
文件: {item.name}
{:else}
文件夹: {item.name}
{#each item.children as child}
{#if child.type === 'file'}
文件: {child.name}
{:else}
...
{/if}
{/each}
{/if}
{/each}
In the preceding code snippet, we used an `{#each}` block to iterate over the items in the `folder` variable, rendering either a file or a folder based on `item.type`. If `item.type` is a folder, we use another nested `{#each}` block to go through its contents.
However, here’s the catch – the inner folder could also contain files or additional folders, making it recursive. As a result, we end up with repeated code for the inner and outer `{#each}` blocks. This works fine for a folder structure that’s only two levels deep, but what if it’s more complex? How can we avoid duplicating the same code for each level of nesting?
Svelte offers an elegant solution to handle such recursive structures with the `<svelte:self>` element. The `<svelte:self>` element allows a component to embed itself within its own markup, thus making it easier to handle recursive data.
Here’s the updated code using the `<``svelte:self>` element:
{/each folder as item}
{#if item.type === 'file'}
文件: {item.name}
{:else}
文件夹: {item.name}
<svelte:self folder={item.children} />
{/if}
{/each}
In the updated code snippet, we replaced the nested `{#each}` block with `<svelte:self folder={item.children} />`. This effectively re-renders the current component, passing `item.children` as the `folder` prop.
The beauty of this is that it eliminates the need to initiate a new `<ul>` element and duplicate `{#each}` blocks for each nesting level. Instead, the component simply reuses itself, making it capable of handling nested structures of any depth.
When comparing this with the previous code snippet, you can immediately see the advantage – it’s much cleaner and avoids repetitive code, making it easier to read and maintain, and it scales naturally to handle any level of nested folders.
Now that we’ve discussed how to tackle recursive data with `<svelte:self>`, let’s look at a practical example in the following section, where we will create a Svelte-based JSON tree viewer.
Example – a JSON tree viewer
In this section, we will walk you through building a JSON tree viewer component in Svelte. The JSON tree viewer component helps you visualize JSON data in a tree-like format. Along the way, we’ll make use of some of the advanced Svelte features we’ve covered in this chapter, including `<svelte:self>`, `<svelte:component>`, and slots.
Before we start, let’s think about what our JSON Tree Viewer should look like and how it should behave. Essentially, a JSON object is made up of key-value pairs, where the values can be either primitives, arrays, or other nested objects. Our goal is to display these key-value pairs in a way that clearly represents the hierarchical structure of the JSON object.
So, let’s create a `JsonTree` component for our JSON tree viewer:
{#each Object.entries(data) as [key, value]}
{key}: {value}
{/each}
In the preceding code snippet, we defined a `JsonTree` component that accepts data as a prop. Inside this component, we utilize Svelte’s `{#each}` block to generate a list of `<li>` elements. Each element displays a key-value pair from the `data` object.
However, these values can vary. They could be primitive types or nested objects. Take the following example:
data = {
name: 'John Doe',
address: {
street: '123 Main St'
}
}
In the preceding `data` object, the `name` key has a primitive string value, while the value for the `address` key is an object. For nested objects like this, we will need to use our `JsonTree` component recursively to render that nested object. This is where `<svelte:self>` comes into play.
Here’s the updated code:
{#each Object.entries(data) as [key, value]}
{key}:
{#if typeof value === 'object'}
<svelte:self data={value} />
{:else}
{value}
{/if}
{/each}
In this updated code snippet, we introduced an `{#if}` block that renders differently based on whether the value is a primitive type or a nested object. For nested objects, we recursively render another `JsonTree` component using `<svelte:self>`. This allows us to elegantly handle JSON objects of any depth.
Now, in our current implementation, we haven’t made distinctions between different types of primitive values. Suppose you’d like to render various primitive types using specialized Svelte components. In that case, you could dynamically switch between different component types using `<svelte:component>`.
Let’s assume you have three separate Svelte components – `StringValue` for strings, `NumberValue` for numbers, and `JsonValue` for others (such as Booleans, `null`, and `undefined`). Here is how you can update the code to use `<svelte:component>`:
{#each Object.entries(data) as [key, value]}
{key}:
{#if typeof value === 'object'}
<svelte:self data={value} />
{:else}
<svelte:component this={getComponent(typeof value)} value={value} />
{/if}
{/each}
In the updated code snippet, we created a utility function named `getComponent` to determine which component to render, based on the type of the primitive value. We then used `<svelte:component this={getComponent(typeof value)} />` to dynamically load the appropriate component for each primitive type, allowing our `JsonTree` component to switch to different components for different types of data.
Lastly, to make our JSON tree viewer more versatile, we can introduce named slots to customize the appearance of keys and values. By doing so, users can easily tailor the look of these elements according to their needs, while keeping our current design as the default fallback. Let’s update the code to add the two named slots:
{#each Object.entries(data) as [key, value]}
{key}:
{#if typeof value === 'object'}
<svelte:self data={value} />
{:else}
<svelte:component this={getComponent(typeof value)} value={value} />
{/if}
{/each}
In the updated code, we added two named slots – one to customize keys, called `"obj-key"`, and another to customize values, called `"obj-value"`. These slots receive the current `key` and `value` as slot props, enabling you to tailor their appearance in the parent component.
For instance, if you wish to change how keys and values are displayed, here’s how you can write in your parent component:
<JsonTree data={{a: 1, b: 2}}>
<svelte:fragment slot="obj-key" let:key>
{key}
</svelte:fragment>
<svelte:fragment slot="obj-value" let:value>
{value}
</svelte:fragment>
Here, we use Svelte’s `<svelte:fragment>` special element to target the named slots, `"obj-key"` and `"obj-value"`, in our `JsonTree` component. The `let:key` and `let:value` syntax allows us to access the current key and value being rendered, respectively.
In the `<svelte:fragment>` element, we wrapped the key in an `<em>` tag and the value in a `<u>` tag, offering custom styling for these elements. This customization overrides the default rendering provided by the `JsonTree` component. If you don’t specify any custom content, the component will fall back to its default rendering, offering both robustness and flexibility.
By combining `<svelte:self>` for recursion, `<svelte:component>` for dynamic behavior, and slots for customization, we have created a flexible and powerful JSON tree viewer. This not only demonstrates Svelte’s capability to handle complex UI patterns but also serves as a practical example of component composition in Svelte.
You can find the complete code for this exercise here: [`github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter04/06-exercise-svelte-json-tree`](https://github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter04/06-exercise-svelte-json-tree%0D).
Before we wrap up this chapter, let us take a moment to discuss a helpful common pattern to organize our components, the Container/Presentational pattern.
The Container/Presentational pattern
As your application scales in complexity, you may find it beneficial to adopt specific design patterns or guidelines to structure components. One such approach is the **Container/Presentational pattern**, which divides a component into two categories, the Container component and the Presentational component:
* *Container components* focus on functionality. They handle data fetching, state management, and user interactions. While they usually don’t render **Document Object Model** (**DOM**) elements themselves, they wrap around Presentational components and supply them with data and actions.
* *Presentational components* are all about the user interface. They get their data and event-handling functions exclusively through props, making them highly reusable and straightforward to test.
A common scenario where you’ll see this pattern in action is when using a UI component library. In this case, the library’s components serve as the presentational elements, focusing solely on how things look. Meanwhile, the components you create that utilize these library elements act as Container components, managing data and handling interactions such as clicks, typing, and dragging.
The Component/Presentational pattern is useful in a few aspects:
* **Simplicity**: Separating behavior from appearance makes your codebase easier to understand and maintain
* **Reusability**: Since Presentational components are agnostic about data sources or user actions, you can reuse them in different parts of your application
* **Collaboration**: With this division, designers can work on the Presentational components while developers focus on the Container components, streamlining development
As you come to appreciate the benefits of the Component/Presentational pattern, there are specific situations where I think you should consider using it, such as the following:
* **When your application starts to grow**: Managing everything in a single component can become confusing as complexity increases
* **When you find yourself repeating the same UI patterns**: Creating reusable Presentational components can save time in the long run
* **When your team scales**: As your development team grows, having a standardized way of building components can reduce learning curves and prevent code conflicts
While the Container/Presentational pattern offers a structured approach to organizing your components, it’s not always the best fit – especially for smaller applications, where it might be overkill. Hopefully, with the insights provided, you’ll be better equipped to make informed decisions.
Summary
In this chapter, we delved into various strategies for component composition in Svelte, each offering its own set of advantages and applicable scenarios. Mastering these techniques will equip you to build Svelte applications that are not only more dynamic but also easier to maintain and scale.
We kicked off by discussing multiple ways to influence a child component. These ranged from controlling props or using Svelte’s context to customizing styles via CSS custom properties, and even dynamically passing content through slots.
Then, we turned our attention to some of Svelte’s special elements. We explored `<svelte:element>` and `<svelte:component>` to dynamically render various HTML elements and Svelte components. We also learned about `<svelte:self>`, which allows a component to reference itself, thereby facilitating the creation of recursive UI structures. We then applied these newfound skills to build a JSON tree viewer as an illustrative example.
Finally, we touched upon a popular design pattern – the Container/Presentational pattern. We examined its advantages and considered scenarios where it would be beneficial to employ this approach.
Armed with these advanced techniques, you are now better prepared to tackle complex challenges in your Svelte projects. As we conclude our four-chapter exploration of writing Svelte components, we’ll shift our focus in the upcoming chapter to another fundamental feature of Svelte – Svelte actions.
第二部分:动作
在本部分,我们将开始学习 Svelte 动作。在三个章节中,我们将深入研究 Svelte 动作的三个不同用例。从使用动作创建自定义事件开始,我们将逐步过渡到通过动作将第三方 JavaScript 库与 Svelte 整合。最后,我们将揭示利用 Svelte 动作的力量来渐进式增强我们应用程序的技术。
本部分包含以下章节:
第五章, 使用动作创建自定义事件
第六章, 使用动作整合库
第七章, 使用 Svelte 动作进行渐进式增强
第五章:带有动作的定制事件
动作是 Svelte 最强大的功能之一。
它是封装逻辑和数据到可重用单元的组件的轻量级替代品。它们帮助我们在不同元素上重用相同的逻辑。
虽然组件具有生命周期方法,如 onMount 和 onDestroy,这些方法在组件内的所有元素添加到或从 DOM 中移除时运行,但动作被设计来处理单个元素的逻辑,仅在特定元素添加到或从 DOM 中移除时运行。
虽然组件可以接收并响应属性变化,但你也可以从父组件向子组件传递数据到动作。当数据发生变化时,动作会做出反应,并且你可以指定动作在数据变化时应该如何反应。
动作简单却非常灵活。你可以用它们做很多事情。在本章和下一章中,我们将探讨动作的一些用例。
动作的一个许多用途案例是管理元素的监听器。事件监听器在 Web 应用程序中非常常见,因为它们允许我们针对用户操作实现特定的行为。这使得我们的应用程序更加互动和动态。因此,将很有趣地看到 Svelte 动作如何帮助我们管理事件监听器。
在本章中,我们将首先探讨如何使用动作帮助管理元素的监听器。我们将通过示例和练习来加强这一概念。
本章包括以下内容:
使用动作管理事件监听器
使用动作创建自定义事件
技术要求
你可以在这里找到本章的项目:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter05
定义动作
在我们开始讨论如何使用 Svelte 动作创建自定义事件之前,让我们快速回顾一下如何在 Svelte 中定义动作。
在 Svelte 中,动作不过是一个遵循动作合约的函数。这意味着如果一个函数遵循特定的函数签名,它就被认为是动作。以下是动作的函数签名:
function action(node) {
return {
destroy() {}
};
}
这是一个可选返回具有 destroy 方法的对象的函数。
在这种情况下,由于 action 函数遵循动作合约,它是一个 Svelte 动作。
要在元素上使用 Svelte 动作,你可以使用 use: 指令:
<div use:action />
在这里,我们在 div 元素上使用了名为 action 的 Svelte 动作。
那么,带有 Svelte 动作的 div 元素会发生什么?
当 <div> 元素挂载到 DOM 上时,Svelte 将使用 <div> 元素的引用调用 action 函数:
const action_obj = action(div);
当元素从 DOM 中移除时,Svelte 将调用从调用 action 函数返回的对象中的 destroy 方法:
if (action_obj && action_obj.destroy) action_obj.destroy();
我们可以通过使用参数来自定义 Svelte 动作的行为。我们可以向 action 函数传递一个额外的参数:
function action(node, parameter) {
return {
update(parameter) {},
destroy() {},
};
}
此外,你还可以向返回的对象添加另一个方法,update,当参数的值发生变化时将被调用:
action_obj.update(new_value);
要传递一个额外的参数,你可以使用与 value 属性类似的语法来指定它:
<div use:action={value} />
现在我们已经知道了如何在 Svelte 中使用和定义动作,让我们看看动作的第一个用例。
使用自定义事件重用 DOM 事件逻辑
在我们直接讨论动作之前,让我们看看如何使用 mousedown 和 mouseup 事件来创建长按行为的一个例子。我们将看到这个简单的例子将如何引导我们进入 Svelte 动作:
<script>
let timer;
function handleMousedown() {
timer = setTimeout(() => {
console.log('long press!');
}, 2000);
}
function handleMouseup() {
clearTimeout(timer);
}
</script>
<button
on:mousedown={handleMousedown}
on:mouseup={handleMouseup}
/>
在前面的例子中,我们尝试在按钮中实现长按行为。想法是按住按钮超过两秒钟,然后执行某些操作。当我们检测到是长按时,我们在控制台中打印 'long press!'。
要实现长按行为,我附加了两个事件监听器:mousedown 和 mouseup。这两个事件监听器协同工作。mousedown 使用 setTimeout 开始计时两秒钟,而 mouseup 使用 clearTimeout 清除计时。如果用户没有足够长时间地按住按钮,超时就不会被触发,就不会被视为长按。请注意,为了在两个事件监听器之间协调计时器,timer 变量在事件监听器之间是共享的。
如你所见,要实现长按行为,你需要两个事件监听器和共享的一个变量。
现在,如果你需要在另一个按钮上实现这个长按行为怎么办?
你实际上不能共享相同的事件监听器和变量,因为你可能希望将不同的持续时间视为超时,或者在长按发生时有不同的行为。
因此,你必须再次声明它们,并记住将正确的事件与正确的事件监听器配对。
将逻辑封装到组件中
重新创建不同长按按钮的一种方法是将其封装为一个组件,将所有长按按钮逻辑放入组件中,并作为重用逻辑的手段重用组件。
当你定义一个组件时,你也在定义组件中的逻辑以及元素。这意味着如果我们把长按逻辑和 button 元素都放入组件中,我们必须与 button 元素一起使用长按逻辑,而不能使用其他元素。
如果你想要自定义元素,比如使用不同的元素、不同的样式、显示不同的文本内容或添加更多的事件监听器,你必须定义样式、文本内容或事件监听器作为组件的属性,并将它们传递到组件中的 button 元素:
<!-- LongPressButton.svelte -->
<button
// besides the longpress behavior
on:mousedown={handleMousedown}
on:mouseup={handleMouseup}
// you need to pass down props as attributes
{...$$props}
// and also forward events up
on:click
on:dblclick
>
<slot />
</button>
在前面的代码中,我们将从 props 中传递的额外属性以及从按钮元素中转发两个事件(click和dblclick)到组件中。
我在这里试图说明的是,如果你希望通过组件重用事件监听器逻辑,你将发现自己必须处理与元素一起在组件中出现的其他属性。
我们可以用组件做更多的事情,但如果我们只是尝试重用长按行为,那么通过在组件中定义它来重用它就有点过度了,而且它可能会很快变得难以管理。
那么,我们还有其他什么选择?
将逻辑封装到动作中
使用动作封装长按行为是一个更好的选择。
让我们就这样做,然后我会解释为什么使用动作是一个更好的方法:
function longPress(node) {
let timer;
function handleMousedown() {
timer = setTimeout(() => {
console.log('long press!');
}, 2000);
}
function handleMouseup() {
clearTimeout(timer);
}
node.addEventListener('mousedown', handleMousedown);
node.addEventListener('mouseup', handleMouseup);
return {
destroy() {
node.removeEventListener('mousedown', handleMousedown);
node.removeEventListener('mouseup', handleMouseup);
}
}
}
定义了动作(如前述代码所示),我们可以在多个元素上使用这个动作:
<button use:longPress>Button one</button>
<button use:longPress>Button two</button>
你可以将动作应用于不同类型的元素:
<span use:longPress>Hold on to me</span>
你也可以将它与其他属性或事件监听器一起使用:
<button use:longPress class="..." on:click={...} />
我希望你能看到,longPress动作只封装了长按行为。与LongPressButton组件不同,longPress动作可以很容易地在任何元素中重用。
所以,作为一个经验法则,当你从元素中抽象逻辑时,如果你需要与元素一起抽象它,使用组件是可以的。但如果你只需要从元素中抽象逻辑行为,请使用动作。
动作是抽象元素级逻辑的强大工具。但仍然缺少一个拼图的一部分:我们应该如何为不同的元素添加不同的长按处理程序?我们将在下一部分看到。
向动作传递参数
那么,我们如何自定义longPress动作的行为呢?
由于你在前面的部分已经看到了它,你可能已经猜到了答案。我们可以通过向动作传递参数来自定义动作的行为。
例如,如果我们想在不同的button元素上处理长按动作,我们可以通过动作参数传递一个不同的函数:
<button use:longPress={doSomething1} />
<button use:longPress={doSomething2} />
然后,我们将在动作函数的第二个参数中接收到这个函数:
function longPress(node, fn) {
// ...
}
当被视为长按时,我们调用传递的函数:
fn();
我们可以类似地传递其他参数,例如,将持续时间考虑为长按。
在 Svelte 动作中,你只能传递一个参数。要传递多个参数,你必须将它们转换成一个对象,并将它们作为一个参数传递。
在我们的longPress动作中,我们想要传递当检测到longPress动作时要调用的函数,以及被视为长按的持续时间,到longPress动作中。为了传递这两个值,我们创建一个包含它们作为对象值的对象,并将该对象作为动作参数传递:
<button
use:longPress={{
onLongPress: doSomething1,
duration: 5000
}} />
对象中的某些参数可能是可选的,因此在操作中读取它们时,我们可能需要提供默认值:
function longPress(node, { onLongPress, duration = 1000 }) {
// if not specified, the default duration is 1s
}
在这个阶段,你可能想知道,onLongPress 是否也可以是可选的?
在我们的情况下,这没有太多意义,因为我们的操作的主要目标是检测长按并调用 onLongPress 函数。
然而,这是一个好问题。
在其他一些操作中,你可能会有可选的函数处理程序。
例如,如果我们有一个可以检测元素上不同手势的 gesture 操作,那么每个 gesture 回调函数都是可选的,因为你可能只对其中的一种手势感兴趣:
function gesture(node, { onSwipe, onDrag, onPinch }) { }
在这个 gesture 操作中,onSwipe、onDrag 和 onPinch 函数处理程序是可选的,可能是未定义的。
我们不应该创建一个空函数作为后备,而应该在使用之前检查函数是否已定义:
if (typeof onSwipe === 'function') onSwipe();
这样,我们就不必创建不必要的函数。
然而,当你有多个回调函数并且需要检查它们是否已定义后再调用它们时,这会变得复杂。
有没有更好的处理方式?
是的,确实有。
实际上,在 Svelte 中,有一个更符合习惯的方式,当发生某些操作时,可以通过派发一个自定义事件来通知或调用一个函数。
例如,要确定用户是否长按了 <button> 元素,自然是在 <button> 元素上监听 'longpress' 自定义事件:
<button on:longpress={doSomething1} />
然而,没有名为 'longpress' 的原生事件。
但别担心,我们可以创建一个自定义的 'longpress' 事件。
幸运的是,我们有 longPress 操作来确定用户何时长按按钮。要创建自定义的 'longpress' 事件,我们需要在 longPress 操作中确定用户长按按钮后触发 'longpress' 事件。因此,在确定用户正在长按按钮的代码行中,我们可以从按钮中派发一个自定义事件:
node.dispatchEvent(new CustomEvent('longpress'));
让我们监听 'longpress' 自定义事件,并在我们的操作中创建自定义的 'longpress' 事件。以下是最终的代码:
<script>
function longPress(node, { duration = 1000 } = {}) {
let timer;
function handleMousedown() {
timer = setTimeout(() => {
node.dispatchEvent(new CustomEvent('longpress'));
}, duration);
}
function handleMouseup() {
clearTimeout(timer);
}
node.addEventListener('mousedown', handleMousedown);
node.addEventListener('mouseup', handleMouseup);
return {
destroy() {
node.removeEventListener('mousedown', handleMousedown);
node.removeEventListener('mouseup', handleMouseup);
}
}
}
</script>
<button longPress action to the button element, which adds logic to determine whether the button is being long-pressed. When the button is long-pressed, the longPress action dispatches a custom event called 'longpress' on the element. To react to and trigger specific behaviors when the custom 'longpress' event is dispatched on the element, we can listen to the event by using on:longpress with an event handler.
It may feel like a roundabout way to call a function from an action by dispatching an event, but doing it this way has a few pros:
* Whether we listen to the `'longpress'` event on the button or not, the action could still dispatch the `'longpress'` custom event. So, with this approach, we don’t need to check whether the handler is defined or not.
* Listening to the `'longpress'` event using `on:` instead of passing the function directly into the action would mean that you could use other Svelte features that come with Svelte’s `on:` directive. For example, to only listen to the `'longpress'` event once, you can use the `|once` event modifier, for example, `on:longpress|once`.
Another way of describing what we have done with the `longPress` action is that the `longPress` action enhances the button element and provides a new event, `'longpress'`, that can be listened to on the element.
Now that we’ve learned how we can define Svelte actions, and how we can use actions to create new events that we can listen to on an element, let’s look at a few more examples that use this technique.
Example – validating form inputs with custom events
The example that we are going to explore is using actions to validate form inputs.
When you add an input element to your form, you can add attributes such as `required`, `minlength`, and `min` to indicate that the input value has to pass the constraint validation or else would be considered invalid.
However, by default, such a validation check is only done during form submission. There’s no real-time feedback on whether your input is valid as you type.
To make the input element validate as you type, we need to add an `'input'` event listener (which will be called on every keystroke as we type in the input element) and call `input.checkValidity()` to validate the input. Now, let’s do just that:
<input on:input={(event) => event.target.checkValidity()} />
As you call the `checkValidity()` method, if the input is indeed invalid, then it will trigger the `'``invalid'` event:
<input
on:input={(event) => event.target.checkValidity()}
on:invalid={(event) => console.log(event.target.validity)}
/>
Unfortunately, there’s no `'valid'` event. So, there’s no way to tell whether the input has passed the validation.
It would be great if there were an event called `'validate'` in which within the event details, we can tell whether the input is valid or not. If it isn’t, we could get an error message about why the input is invalid.
Here’s an example of how we could use the `'``validate'` event:
<input on:validate={(event) => {
if (event.detail.isValid) {
errorMessage = '';
} else {
errorMessage = event.detail.errorMessage;
}
}} />
There isn’t an event called `'validate'`, but we can create this custom event ourselves. So, why not create an action to create this custom event for us?
This logic is well suited to be written as an action for the following reasons:
* It can be reused in other input elements.
* This logic itself is not involved in creating or updating elements. If it were, it would probably be better suited to be a component.
Let us write this action:
1. Firstly, this action involves listening to the `'input'` event listener. So, in the code, as shown, we are going to add an event listener at the start of the action and remove the `'input'` event listener in the `destroy` method. This means that whenever an element that uses this action is added to the DOM, it will listen to the `'input'` event, and when it is removed from the DOM, the event listener will be automatically removed:
```
function validateOnType(input) {
input.addEventListener('input', onInput);
return {
destroy() {
input.removeEventListener('input', onInput);
}
};
}
```js
2. Next, within the input handler, we are going to call `checkValidity()` to check whether the input is valid. If the input is invalid, then we will read `input.validity` and determine the error message:
```
function validateOnType(input) {
function onInput() {
const isValid = input.checkValidity();
const errorMessage = isValid ? '' : getErrorMessage(input.validity);
}
// ...
}
```js
3. Finally, we will dispatch the custom `'validate'` event with `isValid` and `errorMessage` as event details:
```
function validateOnType(input) {
function onInput() {
// ...
input.dispatchEvent(
new CustomEvent(
'validate',
{ detail: { isValid, errorMessage } }
)
);
}
// ...
}
```js
4. Now, with this action completed, we can enhance the `<input>` element by adding a new `'validate'` event, which will be called `as-you-type`, letting you know whether the input is currently valid or invalid. It will also show the corresponding error message:
```
<input
use:validateOnType
当:validate={(event) => console.log(event.detail)}
/>
```js
To make the validation results more useful to the user, you can use the result from the `'validate'` event to modify element styles, such as setting the input border color to red when the validation result is invalid, as shown in the following snippet:
```
<script>
let isValid = false;
</script>
<input
类:invalid={!isValid}
使用:validateOnType
当:validate={(event) => isValid = event.detail.isValid}
/>
<style>
.invalid { border: red 1px solid; }
</style>
```js
Are you getting the hang of writing actions that create custom events?
Let’s try the next one as an exercise. This time, we’ll tackle one of the most common user interactions, drag and drop.
Exercise – creating a drag-and-drop event
A drag-and-drop behavior means clicking on an element, moving the mouse while holding down the click to drag the element across the screen to the desired location, and then releasing the mouse click to drop the element in the new location.
A drag-and-drop behavior thus involves coordination between multiple events, namely, `'mousedown'`, `'mousemove'`, and `'mouseup'`.
As we perform the drag-and-drop motion, what we are interested in knowing is when the dragging starts, how far the element is dragged, and when the dragging ends.
These three events can be translated into three custom events: `'dragStart'`, `'dragMove'`, and `'dragEnd'`.
Let us try to implement the drag-and-drop behavior as an action that will create these three custom events:
使用:dragAndDrop
当:dragStart={...}
当:dragMove={...}
当:dragEnd={...}
/>
You can check the answer to this exercise here: [`github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter05/03-drag-and-drop`](https://github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter05/03-drag-and-drop).
Summary
In this chapter, we saw how to define an action. We talked about one of the common patterns of actions, which is to create custom events. This allows us to encapsulate DOM event logic into custom events and reuse them across elements.
In the next chapter, we will look at the next common pattern of actions, which is integrating third-party libraries.
第六章:使用操作整合库
互联网上有许多 JavaScript UI 库。然而,在撰写本书时,Svelte 相对较新。并不是所有的 UI 库都是用 Svelte 编写的,也不是专门为 Svelte 编写的。但这并不意味着我们不能在我们的 Svelte 组件中使用它们。
有许多方法可以将第三方 JavaScript UI 库集成到 Svelte 中。在本章中,我们将探讨如何使用 Svelte 操作来实现这一点。
我们将首先整合一个假设的 UI 库,逐步构建我们的案例,说明为什么 Svelte 操作适合这项工作。在这个过程中,我将解释如何使用 Svelte 操作处理不同的场景,并展示 Svelte 操作在哪些方面不足。我将讨论我选择 Svelte 操作和选择其他选项的理由和个人的观点。
在此之后,我将向你展示一些真实的 UI 库示例。然后,我们将探索如何使用更多示例将用其他框架(如 React 和 Vue)编写的 UI 库集成到 Svelte 中。
到本章结束时,你将看到你不仅限于在你的 Svelte 应用程序中只使用用 Svelte 编写的 UI 库——你可以重用互联网上可用的任何 UI 库。
本章涵盖了以下主题:
将 JavaScript UI 库集成到 Svelte 中
为什么我们应该使用操作来整合 UI 库和其他替代方案
将用其他框架编写的 UI 库集成到 Svelte 中
技术要求
你可以在这里找到本章的示例和代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter06。
将纯 JavaScript UI 库集成到 Svelte 中
首先,我们将探索用纯 JavaScript 编写的 UI 库。当我们使用“纯 JavaScript”这个短语时,我们指的是普通的 JavaScript,或者在没有框架或库的情况下使用的 JavaScript。
有许多原因使得 UI 库是用纯 JavaScript 编写的:
性能原因——没有 Web 框架的抽象,优化会更容易
库作者的个性化偏好,希望库不依赖于任何框架
该库是在任何现代 Web 框架之前创建的
对于我们来说,纯 JavaScript UI 库很棒,因为它们不依赖于任何特定的框架运行时,这给 UI 库本身带来了额外的开销。
例如,如果我们使用一个用 React 实现的日历组件库,那么除了安装日历组件库之外,我们还需要安装 React 的框架。
这个额外的依赖导致包的大小增加,并且可能与 Svelte 发生冲突。因此,当在 Svelte 中使用组件库时,通常更倾向于选择不依赖于任何特定框架的库。
既然我们已经了解了为什么纯 JavaScript UI 库很棒,让我们讨论如何将它们集成到 Svelte 中。在本章中,我们将探索使用 Svelte 动作在 Svelte 中集成库,这引发了一个问题,为什么我们选择使用 Svelte 动作来集成 UI 库?
为什么使用 Svelte 动作来集成 UI 库?
在上一章中,我们探讨了 Svelte 动作在添加自定义事件处理程序方面的有用性。同时,Svelte 动作作为元素级别的生命周期函数,这使得它们在与第三方库交互时非常有用。现在,让我们探讨为什么这是这种情况。
让我们以一个日历组件库为例。为了简化起见,并且避免被实现细节所困扰,让我们想象这个库是一个虚构的库,而不是使用任何真实的日历组件库。这使我们能够专注于一般问题本身,而不是特定库的实现细节。
我们将在之后查看一些真实的 UI 库。
为了决定日历组件将被添加到 DOM 中的位置,组件库通常要求我们指定一个容器元素来容纳库组件。
例如,在这里,ImaginaryCalendar要求我们将容器元素作为构造函数参数的一部分传递:
new ImaginaryCalendar({ container: containerElement })
要获取 Svelte 中元素的引用,我们可以使用bind:this:
<script>
let containerElement;
</script>
<div bind:this={containerElement} />
containerElement变量仅在元素挂载后更新为元素的引用,因此它只能在onMount中引用:
<script>
import { onMount } from 'svelte';
let containerElement;
let calendar;
onMount(() => {
calendar = new ImaginaryCalendar({ container: containerElement });
return () => calendar.cleanup();
});
</script>
注意,我们保留了对calendar实例的引用,因为我们可以使用它来调用日历方法以获取或设置值:
calendar.setDate(date);
此外,当组件卸载时,我们也调用calendar.cleanup()进行清理。
注意
日历库只是一个虚构的例子。然而,大多数 UI 库都会提供类似的 API 或方法来检索或修改组件实例的内部状态,并在不再使用时进行清理。
在这里使用calendar实例时,我们需要格外小心。我们希望避免在初始化之前引用calendar实例,以防止在calendar实例仅在onMount之后声明和初始化时遇到引用错误:
为了安全起见,我们应该在调用calendar实例的任何方法之前检查它是否已定义。在下面的代码示例中,我们在调用calendar.setDate()方法之前验证日历实例是否已定义。
if (calendar) calendar.setDate(date).
当日历条件性地创建时,这种额外谨慎的需求更为明显:
<script>
import { onMount } from 'svelte';
let containerElement;
let calendar;
onMount(() => {
if (containerElement) {
calendar = new ImaginaryCalendar({ container: containerElement });
return () => calendar.cleanup();
}
});
</script>
{#if someCondition}
<div bind:this={containerElement} />
{/if}
在前面的代码中,你可以看到 <div> 是基于 someCondition 条件有条件地创建的。这就是为什么在 onMount 中,我们需要在用 containerElement 作为容器创建 ImaginaryCalendar 之前检查 containerElement 是否可用。并且,只有当 ImaginaryCalendar 被创建时,calendar 实例才可用,因此只有在 someCondition 为 true 时才可用。
前面的代码说明了 calendar 实例可能未定义的许多可能性之一。
关于这段代码,有一点需要注意,它的行为是不正确的,因为它没有尝试在 someCondition 的值从 false 变为 true 时创建 ImaginaryCalendar,也没有在它变回 false 时进行清理。
这正是 Svelte 操作大放异彩的地方。
使用 Svelte 操作
通过修改前面的代码,使其使用操作,你会发现我们不需要额外的检查来确保在实例化 ImaginaryCalendar 之前 containerElement 是可用的。
以下代码展示了如何实现这样的操作。在这里,我们的 Svelte 操作名为 calendar:
<script>
function calendar(containerElement) {
const calendar = new ImaginaryCalendar({ container: containerElement });
return {
destroy() {
calendar.cleanup();
}
};
}
</script>
{#if someCondition}
<div use:calendar />
{/if}
这是因为,当使用 Svelte 操作时,操作函数只有在元素被创建并挂载到 DOM 上时才会被调用,并且只传递元素的引用。
当条件改变到 <div> 元素从 DOM 中移除时,操作的 destroy 方法将被调用以清理资源。
使用 Svelte 操作,我们现在可以在一个组件内创建任意多的 ImaginaryCalendar 实例,只需将操作添加到不同的 HTML 元素中:
为了证明我的观点,在下面的代码片段中,除了在之前的例子中看到的原始 <div> 元素外,我还添加了另一个 <div> 元素和另外三个 <div> 元素,使用 {#each} 块来实现。然后,我将日历操作应用于所有四个 <div> 元素,以创建四个更多的日历,并且一次创建多个日历时我们没有遇到任何错误。
{#if someCondition}
<div use:calendar />
{/if}
<!-- Look we can have as many calendars as we want -->
<div use:calendar />
{#each [1, 2, 3] as item}
<div use:calendar />
{/each}
如果我们要使用 bind:this 和 onMount,我们就必须多次重复自己,通过多次声明多个 containerElement 变量,并使用每个 containerElement 变量实例化 ImaginaryCalendar。
现在,随着 calendar 实例被封装在操作中,我们应该如何从外部调用 calendar 实例方法来更新 calendar 状态?
那就是操作数据的作用!
向 Svelte 操作添加数据
在前面的章节中,我们创建了一个 calendar 操作并在其中实例化了 ImaginaryCalendar 实例。如果我们想在 calendar 操作外部调用 ImaginaryCalendar 实例的方法,例如调用 calendar.setDate(date) 来设置日历的日期,我们应该怎么做?
由于 calendar 实例是在 calendar 动作中定义的,因此无法在 calendar 动作之外调用 calendar.setDate(date)。一种解决方案是通过动作数据传递 date – 也就是说,我们可以将 date 作为动作数据提供,并使用传入的日期调用 calendar.setDate(date)。
例如,在下面的代码片段中,我们将 date 传递给 calendar 动作:
<div use:calendar={date} />
在 calendar 动作中,我们使用传入的日期调用 calendar.setDate(date)。除此之外,我们在动作中定义了一个 update 方法,以便每当传递给 calendar 动作的日期更改时,Svelte 将调用 calendar.setDate(date):
<script>
function calendar(containerElement, date) {
const calendar = new ImaginaryCalendar({ container: containerElement });
calendar.setDate(date);
return {
update(newDate) {
calendar.setDate(newDate);
},
destroy() {
calendar.cleanup();
}
};
}
</script>
<div use:calendar={new Date(2022, 10, 5)} />
在这里,我们可以向不同的 calendar 实例传递不同的日期:
{#each dates as date}
<div use:calendar={date} />
{/each}
那太好了!
现在,如果你想在模式更改时调用不同的 calendar 实例方法,比如 calendar.setMode(),会怎么样?
你可以将 date 和 mode 都传递给动作:
<div use:calendar={{ date, mode }} />
在这种情况下,calendar 动作需要同时处理 date 和 mode:
function calendar(node, { date, mode }) {
const calendar = new ImaginaryCalendar({ container: containerElement });
calendar.setDate(date);
calendar.setMode(mode);
return {
update({ date: newDate, mode: newMode }) {
calendar.setDate(newDate);
calendar.setMode(newMode);
},
destroy() { ... }
};
}
当 date 或 mode 中的任何一个更改时,calendar 动作的 update 方法将被调用。这意味着在先前的代码中,每当 date 或 mode 发生更改时,我们都会调用 calendar.setDate() 和 calendar.setMode()。这可能没有明显的后果,但我们可能正在进行不必要的操作。
解决这个问题的方法是跟踪并始终在 update 方法中检查 date 或 mode 是否已更改。这就是我们如何做到这一点:
function calendar(node, { date, mode }) {
const calendar = new ImaginaryCalendar({ container: containerElement });
calendar.setDate(date);
calendar.setMode(mode);
return {
update({ date: newDate, mode: newMode }) {
if (date !== newDate) {
calendar.setDate(newDate);
date = newDate;
}
if (mode !== newMode) {
calendar.setMode(newMode);
mode = newMode;
}
},
destroy() { ... }
};
}
在先前的代码中,我们正在检查 newDate 是否与当前的 date 不同,如果是的话,我们就调用 calendar.setDate() 方法并更新我们的当前 date 引用。我们对 mode 做了类似的事情。
这有效。然而,正如你所看到的,这比我们最初创建 calendar 动作时设置的代码更多,也更复杂。
那么如果你想要调用一个与任何数据无关的 calendar 实例方法,比如 calendar.refreshDates(),会怎么样?
这就是使用动作的不足之处。
Svelte 动作的一个替代方案
记得之前的例子,我们使用了 bind:this 和 onMount 来初始化 ImaginaryCalendar 吗?
我们说过,这种方法不够灵活,如果需要执行以下操作,就会不足:
条件渲染容器并创建 ImaginaryCalendar
在同一组件内拥有多个日历
这些缺点都是真实的,但有一个用例,使用 bind:this 和 onMount 初始化 ImaginaryCalendar 是完全可行的。这是当之前提到的条件从未为真时:
我们不需要条件渲染容器
我们不需要在同一个组件内拥有多个日历实例(这并不完全正确,但我们会回到这一点)
我不确定你现在是否在想同样的事情,但让我来打破这种悬念。
这时我们想要将 ImaginaryCalendar 作为 Svelte 组件使用。
在 ImaginaryCalendar Svelte 组件内部,我们只有一个容器元素,它始终可用:
<!-- ImaginaryCalendarComponent.svelte -->
<script>
import { onMount } from 'svelte';
let containerElement;
let calendar;
onMount(() => { ... });
</script>
<div bind:this={containerElement} />
然后,你可以有条件地使用这个组件:
<script>
import ImaginaryCalendarComponent from './ ImaginaryCalendarComponent.svelte';
</script>
{#if someCondition}
<ImaginaryCalendarComponent />
{/if}
或者,你可以根据需要多次使用它:
{#if someCondition}
<ImaginaryCalendarComponent />
{/if}
<!-- Look we can have as many calendars as we want -->
<ImaginaryCalendarComponent />
{#each array as item}
<ImaginaryCalendarComponent />
{/each}
在这里,我们使用动作替换了元素,<div use:calendar />,用 Svelte 组件,<``ImaginaryCalendarComponent />。
这很正常。
在上一章中,我们考虑了通过组件或动作进行逻辑抽象。
在这个场景中,我们正在考虑将使用元素作为容器实例化 UI 库的逻辑进行抽象,我们可以将其抽象为一个 Svelte 动作或一个 Svelte 组件。
这两个选项都同样合适。
这两个选项都是为了这个目的而设计的。
那么,你应该选择哪些选项呢?让我们来了解一下。
在 Svelte 动作和 Svelte 组件之间进行选择
当面临选择这两个选项之一时,我的个人偏好如下。
当你在寻找提供以下方面的选项时,选择 Svelte 动作与 UI 库集成:
更轻量级。与 Svelte 动作相比,Svelte 组件有稍微多一点的开销。
只传递零到一份数据到 UI 库组件实例。如果你要将两份或更多数据传递到动作中,那么每当数据中的任何部分发生变化时,动作的更新方法将被调用。
如果你正在寻找提供以下功能的选项,你应该选择 Svelte 组件:
允许更多的优化空间和更精细的控制。
允许你直接调用 UI 库组件实例方法。
允许你将子内容传递到 UI 组件库中。
我们并没有过多讨论这一点,但将 UI 库作为组件集成可以打开通过插槽向 UI 组件库传递额外内容的可能性:
<ImaginaryCalendarComponent>
<!—Customize how each cell of the calendar looks ––>
<svelte:fragment sl"t="c"ll" let:date>
{date}
</svelte:fragment>
</ImaginaryCalendarComponent>
如果你想要了解更多关于插槽和如何在 Svelte 中组合组件的信息,请阅读第四章,在那里我们广泛地探讨了这一主题。现在我们已经介绍了如何使用 Svelte 动作集成 UI 库,为什么我们应该使用 Svelte 动作,以及替代方案和考虑因素,让我们来看一个真实世界的例子,Tippy.js。
示例 - 集成 Tippy.js
Tippy.js 是一个提示框、弹出框、下拉菜单和菜单库。
我与 Tippy.js 库没有任何关联,我选择 Tippy.js 作为示例纯粹是偶然的。不过,Tippy.js 有一个简洁的 API,使其成为示例的好候选。
首先,让我们看看 Tippy.js 的文档:atomiks.github.io/tippyjs/。
在使用我们选择的包管理器安装了 tippy.js 库之后,我们就可以将 Tippy.js 导入到我们的代码中:
import tippy from 'tippy.js';
import 'tippy.js/dist/tippy.css';
现在,我们可以使用以下构造函数初始化 tippy:
tippy('#id');
tippy(document.getElementById('my-element'));
在这里,我们传递了 Tippy.js 应该提供提示的元素。
你可以通过元素的数据属性指定提示内容的任何自定义设置,Tippy.js 将在初始化时拾取这些设置:
<button data-tippy-content="hello" />
或者,你可以在构造函数中传递这个方法:
tippy(element, { content: 'hello' });
要在初始化后更新内容,请调用 Tippy.js 的setContent方法:
tooltipInstance.setContent("bye");
要永久销毁并清理提示实例,Tippy.js 提供了destroy方法:
tooltipInstance.destroy();
这里,我们已经有了一切创建tippy动作所需的东西。我们有以下方法:
创建tippy提示——tippy(…)
清理tippy提示——tooltipInstance.destroy()
更新tippy提示——tooltipInstance.setContent(…)
让我们看看tippy动作应该是什么样子。
这是我希望它看起来像的样子:
<div use:tippy={tooltipContent} />
在前面的代码片段中,我们将我们的tippy动作应用于一个<div>元素。由 Tippy.js 创建的提示内容应该作为动作数据传递给tippy动作,表示为tooltipContent变量。每当tooltipContent更改时,动作应该对其做出反应并更新提示。
因此,让我们编写我们的tippy Svelte 动作。以下是动作的脚手架:
function tippy(element, content) {
// TODO #1: initialize the library
return {
update(newContent) {
// TODO #2: do something when action data changes
},
destroy() {
// TODO #3: clean up
}
};
}
如您所见,我们基于 Svelte 动作合约创建了tippy动作:一个返回包含destroy和update方法的对象的函数。
我在代码中留下了三个TODO,每个都标记了 Svelte 动作的不同阶段。让我们逐一过一遍并填补它们。第一个TODO是动作在元素创建并挂载到 DOM 后被调用的地方。在这里,我们得到了动作应用到的元素和动作数据,我们应该使用它来初始化 Tippy.js 提示:
// TODO #1: initialize the library
const tooltipInstance = tippy(element, { content });
第二个TODO位于update方法中。这个方法将在动作数据每次更改时被调用。在这里,我们需要调用 Tippy.js 提示实例以反映 Svelte 组件中的数据集:
// TODO #2: do something when action data changes
tooltipInstance.setContent(newContent);
第三个TODO位于destroy方法中。这个方法将在元素从 DOM 中移除后被调用。在这里,我们需要清理我们在动作中创建的 Tippy.js 提示实例:
// TODO #3: clean up
tooltipInstance.destroy();
就这样——我们现在有一个工作的tippy动作,它集成了 Tippy.js 提示,并且每当我们将鼠标悬停在元素上时,都会显示一个具有自定义内容的提示。
你可以在 GitHub 上找到完整的代码:github.com/PacktPublishing/Practical-Svelte/tree/main/Chapter06/01-tippy.
让我们再看一个例子,通过这个例子我想向你展示在集成 UI 库时可以使用动作做的一些其他事情。
我们接下来要查看的 UI 库是 CodeMirror。
示例——集成 CodeMirror
CodeMirror 是一个具有许多出色编辑功能的代码编辑组件,例如语法高亮、代码折叠等。
你可以在codemirror.net/找到 CodeMirror 的文档。
在撰写本文时,CodeMirror 目前处于版本 5.65.9。
在使用我们选择的包管理器安装了codemirror库之后,我们可以在代码中导入codemirror:
import CodeMirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
现在,我们可以使用以下构造函数初始化 CodeMirror:
const myCodeMirror = CodeMirror(document.body);
在这里,我们传递了我们要放置 CodeMirror 代码编辑器的元素。
在我继续之前,请注意,我们现在从 CodeMirror 中寻找的是同一组东西:
初始化 CodeMirror 的方法
清理 CodeMirror 实例所需的任何方法
任何更新 CodeMirror 实例的方法
我将把这个清单的完成和解决方法留给你。
然而,请允许我特别提醒您注意 CodeMirror 实例中的一个特定 API:
myCodeMirror.on('change', () => { ... });
CodeMirror 的on方法允许 CodeMirror 实例监听事件并对它们做出反应。
所以,如果我们想从动作外部向 CodeMirror 实例添加事件监听器,我们应该怎么做?
在上一章中,我们看到了我们可以在元素上使用操作来创建自定义事件。
这意味着我们可以允许用户监听使用codemirror动作的元素的'change'事件:
<div use:codemirror on:change={onChangeHandler} />
要实现这一点,你可以在动作内部触发一个事件:
function codemirror(element) {
const editor = CodeMirror(element);
editor.on('change', () => {
// trigger 'change' event on the element
// whenever the editor changes
element.dispatchEvent(new CustomEvent('change'));
});
}
请记住,在destroy方法中检查是否需要清理或取消监听任何事件,以免引起任何不期望的行为。
就这样!
其余的操作留给你作为练习。
你可以在 GitHub 上找到完整的代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter06/02-codemirror。
在本节中,我们学习了如何将纯 UI 库集成到 Svelte 中。然而,并非所有 UI 库都是独立实现的,没有任何框架。有时,你可能需要的库可能是在不同的框架中实现的,比如 React 或 Vue。在这种情况下,如何将它们集成到 Svelte 应用程序中?这就是我们接下来要探讨的。
使用其他框架编写的 UI 库
在 Svelte 中使用其他框架的组件并非不可能。
然而,这样做将会引入框架的运行时和其他与框架相关的开销。运行时通常包括处理响应性和标准化浏览器 API 和事件的代码。每个框架通常为其逻辑提供自己的代码,并且不会与其他框架共享。React 版本 18.2.0 的运行时在压缩后重量为 6.4 kB,这是当你想在 Svelte 中使用 React 组件时需要包含的额外代码。
因此,除非必要,否则不建议这样做。
这一节被包含在这本书中更多的是出于教育目的,以及为了展示这是可能的,以及为了实现它需要做什么。
在各种框架中创建组件
每个框架通常提供一个 API,它接受一个容器元素和框架组件作为应用程序的根。
在本节中,我们将探讨 React 和 Vue,这两个在撰写本文时最受欢迎的 JavaScript 框架。
例如,在 React 18 中,我们使用 createRoot API:
import ReactDOM from 'react-dom';
const root = ReactDOM.createRoot(container);
root.render(<h1>Hello, world</h1>);
前面的代码使用了 JSX 语法,这不是标准 JavaScript 语言的语法。它是 jsx 的语法糖:
import { jsx } from 'react/jsx-runtime';
const root = ReactDOM.createRoot(container)
root.render(jsx('h1', { children: 'Hello, world' }));
如果你没有在代码中配置任何将 JSX 语法转换为有效 JavaScript 的转换过程,你将不得不编写前面的代码。
另一方面,在 Vue 3 中,有 createApp API:
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App);
app.mount(container);
Vue 框架在文档中使用“application”这个词,提到 createApp 方法用于创建一个新的应用程序实例。这个词“application”用得恰到好处,因为将其他框架编写的组件库集成到我们的 Svelte 应用程序中,就像是在我们的 Svelte 应用程序中启动一个新的子应用程序一样。
你可能也已经注意到了这些框架的 API 与我们之前看到的其他 UI 库之间的相似性——它们都接受一个容器元素,以便知道在哪里渲染内容或应用更改。
与使用动作集成 UI 库类似,在弄清楚我们可以使用哪些 API 在容器元素内渲染组件之后,接下来我们必须检查是否有任何 API 在不再需要时进行清理。
在各种框架中清理组件
根据组件库的底层框架,提供了不同的 API 来清理不再需要的组件。
在 React 中,有一个名为 unmount 的方法来做这件事:
root.unmount();
在 Vue 中,这也被称为 unmount:
app.unmount();
我们接下来需要检查的是是否有任何 API 可以将数据传递到我们的组件和 API 中,以便它们可以在以后进行更新。
在各种框架中更新组件
与不同框架提供不同的清理 API 类似,框架提供了不同的 API 来用新数据更新组件。
如果你熟悉 React,你可以通过 props 将数据传递给 React 组件:
<Component prop_name={value} />
这类似于 Svelte 组件中的 props。
前面的代码简化为以下内容:
jsx(Component, { prop_name: value });
要更新组件的 props,React 允许我们再次调用 root.render,使用相同的组件但不同的 prop 值:
root.render(jsx(App, { prop_name: 123 }));
// some time later
root.render(jsx(App, { prop_name: 456 }));
React 将内部进行协调并找出如何更新 DOM。
另一方面,在 Vue 中,你可以在 createApp API 中通过 props 传递数据:
const app = createApp(Component, { prop_name: value });
然而,据我所知,没有直接更新 props 值的简单方法。
然而,你可以使用 Vue 的组合式 API,例如 ref(),来创建一个可响应和可变的 ref。有了这个,你可以修改 ref 而不是直接更新 props:
const value = ref(123);
const app = createApp(Component, { prop_name: ref });
// some time later
value.value = 456;
如果你不太熟悉 React 和 Vue 的工作原理,那也无所谓。这本书是针对 Svelte 的,而不是针对 React 或 Vue。
从这个例子中我们学到最重要的一点是,在集成 UI 库时,无论是使用纯 JavaScript、React 还是 Vue,我们都在寻找三件事:
一种创建带有容器元素组件的方法
一种清理组件实例的方法
一种传递数据和更新数据的方法
如果你熟悉某个框架,你将能够找到一种方法来完成所有这些事情。
现在我们已经解决了这个问题,让我们看看一个现实世界的例子,我们将集成一个 React 日历库,react-calendar。
将 react-calendar 集成到 Svelte 中
react-calendar库是一个用 React 编写的日历组件库。
你可以在这里了解更多信息:projects.wojtekmaj.pl/react-calendar/.
react-calendar库接受各种属性以进行定制。但为了演示目的,我们只关注两个属性,value和onChange,这两个属性允许我们控制库选择的日期。
我们通过一个名为value的属性传递选定的日期。另一方面,onChange属性用于传递一个事件处理器,当值在日历组件内部发生变化时将被调用。我们在上一节讨论 CodeMirror 时看到了如何处理事件处理器。
因此,这就是我认为使用calendar动作的样子:
<div
use:calendar={selectedDate}
on:change={(event) => selectedDate = event.detail}
/>
这里,event.detail是附加到自定义'change'事件的 数据,这将是从react-calendar组件通过onChange属性发送的日期值。
现在我们知道了我们的calendar动作会是什么样子,让我们把它写出来。
再次,这是动作的框架:
function calendar(element, date) {
// TODO #1: render the react-calendar into the element
// TODO #2: the onChange handler to dispatch a new custom event
return {
update(newDate) {
// TODO #3: re-render the calendar again when there's a new date value
},
destroy() {
// TODO #4: clean up
}
};
}
在这里,我创建了一个 Svelte 动作的基本代码结构,并在代码中留下了一些 TODOs。前两个 TODOs 是设置动作应用的元素上的calendar实例。第三个TODO是处理当新日期传递到动作中时的情况,最后一个TODO是当元素从 DOM 中移除时进行清理。
因此,让我们填写 TODOs。
对于第一个TODO,让我们创建一个 React 根实例并渲染我们的react-calendar组件:
import { jsx } from 'react/jsx-runtime';
import ReactDOM from 'react-dom';
function calendar(element, date) {
# TODO #1: render the react-calendar into the element
const app = ReactDOM.createRoot(element);
app.render(jsx(Calendar, { value: date, onChange }));
// ...
}
这里,我们传递了onChange,但我们还没有定义它。
让我们来做这件事:
# TODO #2: the onChange handler to dispatch a new custom event
function onChange(value) {
element.dispatchEvent(
new CustomEvent('change', { detail: value })
);
}
在前面的代码片段中,每当调用onChange时,我们将分派一个新的自定义事件,并将value作为自定义事件的详细信息传递。
第三个TODO是update方法的内容。每当从动作传递新的日期值时,我们将重新渲染Calendar组件:
// TODO #3: re-render the calendar again when there's a new date value
app.render(jsx(Calendar, { value: newDate, onChange }));
在最后一个TODO中,在destroy方法中,我们卸载了Calendar组件:
// TODO #4: clean up
app.unmount();
就这样了。
你可以在 GitHub 上找到完整的代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter06/03-react-calendar.
这样,你就已经编写了一个 Svelte 动作,该动作将来自不同框架的组件库(React)集成到 Svelte 中,并且以受控的方式设置和更新了组件的值。
摘要
在本章中,我们学习了如何使用动作将 UI 库集成到 Svelte 中,无论是用纯 JavaScript 编写的还是任何其他框架。我们通过两个真实世界的例子进行了演示——使用 Svelte 动作将 Tippy.js 和 react-calendar 集成到 Svelte 中。在两个例子中,我们都经历了一个逐步的过程来编写 Svelte 动作。我们首先创建了 Svelte 动作的结构,然后在动作中填充了步骤,包括当 Svelte 动作初始化为元素创建时、数据变化时以及元素从 DOM 中移除时的步骤。我们还讨论了为什么选择使用 Svelte 动作,以及集成 UI 库时的其他替代方案和考虑因素。
在下一章中,我们将探讨动作的下一个常见模式,即逐步增强你的元素。
第七章:带有操作的渐进增强
渐进增强是网络开发中的一种设计理念,强调为所有人提供内容和核心功能,同时为负担得起更好浏览器、更强大的硬件和更高互联网带宽的用户提供增强体验。
在本章中,我们将从更深入的角度讨论什么是渐进增强。在您的应用中实现渐进增强有许多方法;我们将通过使用 Svelte 操作来探索其中的一种。我将解释我为什么认为 Svelte 操作是为这种用例设计的。
在本章的末尾,我们将通过几个示例来展示如何使用 Svelte 操作来渐进增强我们的应用程序。
到本章结束时,您将能够构建一个遵循渐进增强原则并支持尽可能多用户设备的应用程序。
本章涵盖了以下主题:
什么是渐进增强?
为什么使用 Svelte 操作进行渐进增强?
使用 Svelte 操作进行渐进增强的示例
技术要求
您可以在此处找到本章的示例和代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter07。
什么是渐进增强?
在“渐进增强”这个短语中,最重要的事情却缺失了。这里隐含的是我们从哪里开始渐进增强。
渐进增强的主要思想是为每个人提供一个优秀的基线,包括基本内容和核心功能,无论浏览器软件、设备硬件还是互联网连接的质量如何。较旧的浏览器软件可能不支持较新的 JavaScript 语法和 CSS 功能;较旧的设备硬件可能需要更多时间来处理和渲染您的网页;较慢的互联网连接可能需要更长的时间来加载显示网页所需的资源。
我们如何确保我们的网页尽可能多地为用户保持可用性?思考一下这个问题——我稍后会回到它。
对于能够负担得起更好浏览器、更强大硬件和更高互联网带宽的用户,我们为他们提供增强体验。我们利用 JavaScript 和 CSS 的力量来惊喜和取悦我们的用户。
我们如何区分用户并决定何时提供增强体验?
有另一个术语经常与渐进增强进行比较,那就是优雅降级。优雅降级从功能丰富的基线开始,优雅地处理用户浏览器无法再支持功能的情况,通过替换为更简单的替代体验来处理。很多时候,这些功能从更复杂的假设开始,因此在执行上,要优雅地降级到各种用户要困难得多。
逐步增强,另一方面,从大多数用户都能使用的基线开始,通过添加更多功能逐步提升。因此,我们可以确保当新功能没有加载或无法工作时,用户仍然有一个基本可用的网页。
那么,让我们回到我们的问题:我们如何确保我们的网页对所有用户都是可用的? 我们将在下一节中揭开这个秘密。
逐步增强网络体验
其中一种方法是我们确保遵循标准。HTML、CSS 和 JavaScript 是网络的主要语言。我们确保我们只使用标准规范中的语言特性。那些已经包含在规范中较长时间的特性更有可能被所有浏览器实现。最新和最热门的特性在所有浏览器中可能不太可用。
因此,使用标准的 HTML、CSS 和 JavaScript 构建你的网页。
这就引出了下一个问题:我们如何根据用户的浏览器、设备和 网络能力提供差异化的用户体验?
有许多种方法可以做到这一点。
对于大多数方法来说,一个关键的想法是分层构建你的应用程序。从核心功能的第一层开始,确保一切正常工作。然后,添加随后的层来增强体验。
分层构建网页
分层构建网页的一个例子是,首先在 HTML 中构建基础内容和功能作为基础层,然后使用 CSS 作为下一层添加样式、过渡和动画。最后,使用 JavaScript 作为最终层添加复杂的交互性。
这与浏览器加载你的网站的方式相一致。
每当用户访问你的网站时,浏览器首先从你的网站下载的是 HTML。HTML 描述了你的内容。例如,<p>、<div>和<table>标签描述了内容在屏幕上的布局方式。而<form>、<input>和<a>标签描述了用户如何提交数据和与内容交互。
使用 HTML,你的网站应该已经为用户提供基本的内容和功能。
如果你 HTML 中有<link rel="stylesheet">,并引用外部 CSS 文件,浏览器将会分别发出请求下载 CSS 资源,然后解析并应用 CSS 样式到你的文档上。这将反过来增强默认浏览器样式的视觉效果。HTML 可以提供基本的布局,但有了 CSS,你可以拥有更高级的布局,例如弹性布局、网格布局等。
另一方面,如果你的 HTML 中有<script>标签,浏览器将会寻找并加载引用的 JavaScript 文件,一旦 JavaScript 文件下载完成,浏览器将会解析并执行它们。JavaScript 可以用来动态地更改 DOM,执行计算,并为网站添加交互性。
没有 JavaScript,仅使用 HTML 表单即可让用户提交数据;然而,在提交后,浏览器将根据表单操作导航到新的位置。使用 JavaScript,您可以在用户继续在同一页面上浏览的同时,异步发送 HTTP 请求将数据发送到服务器。
因此,正如您所看到的,将 HTML 作为基础体验层,并在其上添加 CSS 和 JavaScript 以提供增强体验,这是一种渐进式增强。
拥有较旧浏览器、较慢硬件和较低互联网带宽的用户仍然可以在等待 CSS 和 JavaScript 下载和执行以获得更丰富体验的同时,仅使用 HTML 查看和与您的网站互动。
希望您现在已经对第一种 HTML 方法深信不疑。但假设您的网站内容是动态的?您如何为用户生成动态 HTML?您需要编写单独的代码来生成动态 HTML 吗?
不,您不需要。
Svelte 支持 服务器端渲染(SSR)。这意味着相同的 Svelte 组件可以用于在浏览器上渲染内容,同时在服务器端生成 HTML。
您可以自己设置它(然而,这超出了本书的范围),或者您可以使用像 SvelteKit 这样的元框架,它全面概述了所有应该如何工作。
从这里可以吸取的一点是,无论您的设置如何,您都可以像这样编写您的 Svelte 组件,并且您编写的相同 Svelte 组件代码可以用于在服务器端生成 HTML 和在浏览器端渲染内容。
这引发了一个问题:所有代码在服务器端和浏览器端是否都以相同的方式工作?是否有只在服务器端运行而不在浏览器端运行的代码,或者相反?
好吧,并非所有代码都在服务器端和浏览器端运行。Svelte 动作,以及 bind: 指令和 on: 事件监听器,是 Svelte 功能,它们不会在服务器端和浏览器端同时运行。Svelte 动作仅在浏览器端运行,不在服务器端运行。这是因为 Svelte 动作在元素添加到 DOM 之后运行。由于在服务器端生成 HTML 字符串时没有 DOM,因此 Svelte 动作不会在服务器端运行。
这使得 Svelte 动作成为渐进式增强的完美候选者。
Svelte 动作用于渐进式增强
在上一节中,我们学习了渐进式增强以及以分层构建网页以实现渐进式增强的概念。
在本节中,我们将更深入地探讨 Svelte 动作的作用,这些动作使我们能够为现有的 HTML 元素添加额外的交互层,使它们非常适合创建渐进式增强。
让我们从检查一个 Svelte 代码示例开始,以了解 Svelte 动作如何融入这种方法。
现在想象以下代码:
<button use:enhance />
当从服务器端渲染时,您得到的 HTML 看起来像这样:
<button></button>
仅凭 HTML button 元素本身应该能够完成按钮元素应有的功能:可点击并能提交表单。
但随着 JavaScript 的加载和 Svelte 组件代码的执行,enhance 动作就会在 button 元素上运行,允许动作代码增强 button 元素。
这里有一些动作可能执行的操作示例:在悬停时显示有用的工具提示,在按下时提供加载指示器,等等。
对于那些在客户端运行 Svelte 组件代码时遇到困难的旧版浏览器用户,他们仍然可以使用和交互默认的 HTML 按钮元素,并体验一个未增强的网页版本。
从本节中我们可以了解到,Svelte 动作使我们能够为现有的 HTML 元素添加另一层交互性。它们是设计渐进增强的理想候选者。
因此,让我们看看一些使用动作来逐步增强 HTML 元素的示例。
我们将要查看的第一个示例是逐步增强 <a> 元素。
示例 - 使用 元素预览链接
在我们的第一个示例中,我们将探讨如何逐步增强 <a> 元素,以便在悬停时显示预览。
在这里,浏览器接收到的 HTML 包含一个具有 href 属性的 <a> 标签,如下所示:
<a href="..." />
然后创建一个超链接。当你点击超链接时,浏览器将导航到 href 属性中指定的目标。
这是 <a> 元素的默认行为。
当用户加载 JavaScript 时,我们希望 <a> 元素不仅仅能导航到新位置。
我们将通过使元素在悬停时显示目标位置来增强 <a> 元素。
为了做到这一点,我们将创建一个 preview 动作并在 <a> 元素上使用它:
<script>
function preview(element) {
}
</script>
<a href="..." use:preview>Hello</a>
无论在服务器端渲染前面的代码时 preview 动作是如何实现的,Svelte 都会生成以下 HTML:
<a href="..." />Hello</a>
这是因为 Svelte 动作永远不会在服务器端运行。
一旦用户收到 HTML 响应,他们就可以开始点击链接并导航到新位置。现在你有一个仅使用 HTML 就能工作的应用程序。
根据用户的网络条件,从你的 Svelte 组件编译的 JavaScript 代码可能需要更长的时间才能到达。但这并不会阻止用户使用超链接进行导航。
只有当 JavaScript 加载并执行时,Svelte 才会在 DOM 上的 <a> 元素上运行 preview 动作,并增强 <a> 元素的行为。
这里的关键是,尽可能多地使我们的应用程序的核心功能仅通过 HTML 就能工作,并通过 JavaScript 添加一个增强层,这取决于用户的网络条件,可能要晚得多。
足够了关于渐进增强哲学。让我们看看我们如何实现这个 preview 动作。
我们希望 preview 动作在将鼠标光标移至链接时显示包含链接内容的浮动弹出窗口,并在我们将鼠标光标移开时隐藏它。
我们可以通过 'mouseover' 和 'mouseout' 事件来实现这一点。下面是如何操作的:
function preview(element) {
element.addEventListener('mouseover', onMouseOver);
element.addEventListener('mouseout', onMouseOut);
return {
destroy() {
element.removeEventListener('mouseover', onMouseOver);
element.removeEventListener('mouseout', onMouseOut);
}
};
}
在前面的代码片段中,我们在 preview 动作开始时添加了 'mouseover' 和 'mouseout' 事件监听器。此外,我们通过在 destroy 方法中移除这两个事件监听器来确保适当的清理。在我们弄清楚如何实现 onMouseOver 和 onMouseOut 之前,我们首先需要决定浮动弹出窗口的外观以及如何在 DOM 中布局它。
为了显示链接目标的内联内容,我们将使用 <iframe> 元素,它允许我们将另一个 HTML 页面嵌入到当前页面中:
<iframe src="img/..." />
为了使 <iframe> 元素浮在其他内容之上而不是成为文档流的一部分,我们需要通过使用 position: fixed 或 position: absolute 来修改 <iframe> 元素的 CSS position 属性。
如果我们在 <iframe> 元素上使用 position: fixed,那么 <iframe> 元素将相对于视口进行定位。为了将 <iframe> 元素放置在 <a> 元素旁边,我们需要确定 <a> 元素相对于视口的定位,并计算顶部和左侧的值以放置我们的 <iframe> 元素。
另一方面,如果我们使用 position: absolute,那么 <iframe> 元素将相对于最近的定位父元素进行定位。我们可以将 <iframe> 元素放在 <a> 元素内部,并通过在 <a> 元素上指定 position: relative 来使其成为定位父元素(position: relative CSS 属性是相对于其当前位置进行定位)。然后 <iframe> 元素将相对于其父元素,即 <a> 元素进行定位。
两种方法都有其优缺点。在这里,我将使用第二种方法,即使用 position: absolute。我需要修改内容和 <a> 元素的 position CSS 属性,但如果我使用 position: absolute 而不是 position: fixed,我就可以避免进行计算。
下面是我们将 <iframe> 元素放入 <a> 元素内部后 DOM 的样子:
<a href="..." style="position: relative">Hello<iframe src="img/..." style="position: absolute"/></a>
我们现在的任务是使用程序在 onMouseOver 函数中创建并插入 <iframe> 元素。以下代码片段展示了如何实现这一点:
function preview(element) {
// make the <a> element position relative
element.style.position = 'relative';
let iframe;
function onMouseOver() {
iframe = document.createElement('iframe');
iframe.src = element.getAttribute('href');
iframe.style.position = 'absolute';
iframe.style.left = 0;
iframe.style.top = '100%';
element.appendChild(iframe);
}
// ...
}
在前面的代码片段中,我们将 <a> 元素的 CSS position 属性设置为 'relative'。在鼠标悬停在 <a> 元素上时将被调用的 onMouseOver 函数中,我们将程序化创建一个 <iframe> 元素,设置其样式,并将其插入到 <a> 元素中。
在前面的代码中,我们使用了 DOM API,如 document.createElement() 和 element.appendChild()。由于 <iframe> 元素是程序创建的,我们也在程序上修改了它的 style 属性。
幸运的是,在这个例子中,我们只创建了一个元素,但你可以想象,如果我们创建更多元素,这会多么容易变得混乱。
由于我们在这里学习 Svelte,而 Svelte 是设计用来将这些命令式 DOM 指令抽象成声明式 Svelte 语法,为什么不在我们的操作中利用 Svelte 呢?
我们可以用 Svelte 组件替换之前的命令式代码,如下所示:
<!-- IframePopup.svelte -->
<script>
export let src;
</script>
<iframe {src} />
<style>
iframe {
position: absolute;
left: 0;
top: 100%;
}
</style>
前面的代码片段显示了一个包含 <iframe> 元素的 Svelte 组件。这个 <iframe> 元素与我们之前代码中程序创建的元素等效。它应用了相同的 CSS 样式。
Svelte 组件暴露了一个名为 src 的属性,src 属性的值将被用来设置 <iframe> 元素的 src 属性的值。现在,我们不再需要调用 DOM API 来创建 <iframe> 元素,而是可以实例化我们的 Svelte 组件,并将期望的 src 值作为 src 属性传递给组件。在 Svelte 中,你可以通过使用 new 关键字和组件的构造函数来实例化一个组件,将任何所需的属性作为构造函数参数的一部分传递。然后,Svelte 组件将根据传递的属性渲染具有指定 src 属性值的 <iframe> 元素。这简化了在 Svelte 应用程序中创建和管理 <iframe> 元素的过程:
import IframePopup from './IframePopup.svelte';
function preview(element) {
// ...
function onMouseOver() {
iframe = new IframePopup({
// target specifies where we want the component
// to be inserted into
target: element,
// we are passing the href value into the
// component through props
props: { src: element.getAttribute('href') },
});
}
// ...
}
在前面的代码片段中,我们将 onMouseOver 函数中的 DOM 操作替换为实例化 IframePopup Svelte 组件。
当我们移动鼠标离开链接时,我们需要记住移除弹出窗口。以下是我们可以这样做的方法:
function onMouseOut() {
iframe.$destroy();
}
虽然我们只通过 Svelte 动作将一个 HTML 元素插入 DOM,但我们已经看到,将其封装到一个 Svelte 组件中并实例化该组件,而不是手动创建 HTML 元素,要容易管理得多。
然后,我们可以利用 Svelte 创建作用域样式,以及向我们将要创建的元素添加其他交互逻辑。
我们还可以向元素添加过渡效果;而不是在悬停时突然出现,我们可以使用 fade 过渡效果使弹出窗口淡入,如下所示:
<!-- IframePopup.svelte -->
<script>
import { fade } from 'svelte/transition';
// ...
</script>
<iframe {src} transition:fade />
默认情况下,当组件首次创建时,不会播放过渡效果。因此,我们需要传递 intro: true 选项来播放过渡效果:
iframe = new IframePopup({
target: element,
props: { src: element.getAttribute('href') },
// play transition when created
intro: true,
});
在前面的代码片段中,我们将 intro: true 传递给 IframePopup 构造函数。
我们现在有一个显示在弹出窗口中的链接,悬停时会淡入。尝试模拟使用慢速网络加载页面。大多数浏览器都提供开发者工具来模拟网络速度。例如,如果你使用 Google Chrome,那么你可以打开开发者工具,找到网络条件选项卡,查找网络限制部分,并选择慢速 3G预设。
尝试重新加载你的页面,你会发现一旦你看到链接(尽管 JavaScript 文件仍在加载),链接就会立即生效;点击它将带你到目的地。随着 JavaScript 最终加载到浏览器中,你的链接现在得到了增强,你现在能够悬停在链接上并看到预览。
我们的链接预览动作完成后,让我们看看网络应用中的另一个常见组件,表单,并看看我们如何逐步增强表单元素。
示例 - 逐步增强表单
一个<form>元素是文档的一部分,可以包含用于提交信息的输入。
默认情况下,当你提交表单时,浏览器将导航到 URL 以处理表单提交。这意味着当用户提交表单并离开当前页面时,他们会丢失他们所在的状态。
然而,通过浏览器fetch API 的异步请求能力,我们现在可以通过 API 请求提交数据,而无需离开当前页面,并保持在原地。
这意味着如果网站正在播放音乐、视频或动画,它们在我们进行异步 API 调用时仍然会播放。
我们现在的任务是创建一个动作来增强表单元素,使得增强后的表单不会导航到新位置,而是异步提交表单数据。
由于没有更好的名字,我将把这个增强动作称为enhance。
在我们继续实施增强动作之前,让我们回顾一下默认表单行为。
默认表单行为
当你有一个<form>元素时,默认情况下,当你点击action属性时,它会携带<form>元素内填充的<input>元素的值。
例如,想象你有一个以下表单:
<form action="/foo">
<input name="name" />
<input name="address" />
<button>Submit</button>
</form>
当你输入/foo?name=xxx&address=yyy,通过查询参数携带表单数据。
一个<form>元素可以通过指定method属性来定义用于提交表单的 HTTP 方法。
例如,以下表单将通过POST请求导航到/foo:
<form action="/foo" method="post">...</form>
表单数据将以请求体形式发送到POST请求。
根据服务器对/foo端点的实现,服务器可以选择如何处理数据以及要在/foo页面上显示什么。有时,服务器可能会决定在处理数据后重定向回当前页面。在这种情况下,有一个可以替换默认表单动作并异步提交表单数据的动作将非常有用,因为我们最终会回到同一个页面。
现在我们知道了默认的表单行为,让我们弄清楚我们需要实现enhance动作所需的内容。
实现增强动作
让我们分解这个问题。
首先,我们需要弄清楚何时用户提交表单。然后,我们需要防止默认的表单行为,然后异步调用 API 提交表单,最后将表单重置到初始状态,类似于服务器重定向回同一页面后的情况。
为了确定用户何时提交表单,我们可以监听<form>元素上的'submit'事件:
form.addEventListener('submit', handleSubmit);
为了防止默认的表单行为,我们在'submit'事件监听器上调用event.preventDefault()以防止默认提交行为:
function handleSubmit(event) {
event.preventDefault();
}
为了异步提交表单的 API 调用,我们首先需要找出我们正在提交表单的位置。我们可以从读取form实例的action属性来获取这个信息:
form.action; // "https://domain/foo"
我们还可以从form实例中确定首选的 HTTP 请求方法:
form.method // "post"
要获取提交的表单数据,我们可以使用FormData接口:
const data = new FormData(form);
在有了 URL、请求方法和数据后,我们可以使用fetch API 提交表单:
fetch(form.action, {
method: form.method,
body: new FormData(form),
});
最后,为了重置表单,我们可以使用reset()方法:
form.reset()
将所有内容整合起来,我们得到以下内容:
<script>
function enhance(form) {
async function handleSubmit(event) {
event.preventDefault();
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
});
form.reset();
}
form.addEventListener('submit', handleSubmit);
return {
destroy() {
form.removeEventListener('submit', handleSubmit);
}
};
}
</script>
<form action="/foo" method="post" use:enhance>...</form>
现在,尝试在 JavaScript 加载后提交表单。你会发现有一个网络请求被用来提交表单,而你仍然停留在同一页面上,没有导航离开。
尝试禁用 JavaScript 或模拟缓慢的网络速度。你会发现你仍然可以提交表单,而 JavaScript 仍在加载。然而,这一次,你通过默认的浏览器行为提交,这会将你从页面上导航离开。
这里就是它——默认可用的可工作表单,但通过渐进增强可以在 JavaScript 加载时无需离开页面提交表单数据。
enhance动作有很多可以改进的地方。我将把它留作练习:
修改enhance动作以允许传递一个回调函数,该函数将在表单提交成功后调用。
如果表单提交失败会发生什么?你应该如何处理这种情况?
目前,enhance动作通过请求体提交表单数据;然而,当表单方法为"get"时,表单数据应通过查询参数传递。修改enhance动作以处理"get"表单方法。
摘要
在本章中,我们解释了渐进增强是什么以及为什么它很重要。在此基础上,我们学习了如何使用 Svelte 动作来渐进增强我们的元素。
我们已经探讨了两种不同的渐进增强示例——增强一个链接以显示预览弹出窗口,以及增强表单元素以异步提交表单。
在过去的三章中,我们看到了 Svelte actions 的三种不同模式和用例,包括创建自定义事件、集成 UI 库和渐进式增强。Svelte actions 可以做的不仅仅限于我们之前讨论的三个不同用例,但希望这些模式已经激发了你的想象力,让你看到了 Svelte actions 的可能性。
在此基础上,我们将继续探索本书的下一部分。我们将从 第八章 到 第十二章 探索 Svelte 上下文和 Stores 的各种用例,例如在状态管理、创建无渲染组件以及用于动画。我们将在下一章中定义并比较 Svelte 上下文和 Svelte Stores。
第三部分:上下文和 Stores
在本部分,我们将深入探讨 Svelte 的两个核心特性:Svelte 上下文和 Svelte Stores。在接下来的五章中,我们将探索使用 Svelte 上下文和 Stores 的不同场景。我们的探索将从定义 Svelte 上下文和 Stores 开始。随后,我们将深入研究实现自定义 Stores 和使用 Svelte Stores 管理应用程序状态的战略。接着,我们将学习如何使用 Svelte 上下文创建无渲染组件。最后,我们将学习如何使用 Svelte Stores 创建动画。
本部分包含以下章节:
第八章**,上下文与 Stores 对比
第九章**,实现自定义 Stores
第十章**,使用 Svelte Stores 进行状态管理
第十一章**,无渲染组件
第十二章**,Stores 和动画
第八章:上下文与存储的比较
一个 Svelte 应用可以由一个或多个 Svelte 组件组成。一个 Svelte 组件可以被看作是一个独立的单元,封装了自己的响应式数据和逻辑。在前一章中,我们学习了两个 Svelte 组件——特别是父组件和子组件之间的关系——是如何相互通信和传递数据的。然而,在本章中,我们将探索超出父子和关系的组件间的通信和数据传递。
Svelte 提供了两个原语来在 Svelte 组件间传递数据——Svelte 上下文和 Svelte 存储。Svelte 上下文允许你从祖先组件传递数据到所有子组件,而 Svelte 存储使用观察者模式允许你在多个无关的 Svelte 组件间访问响应式数据。
在接下来的五章节中,我们将探讨 Svelte 上下文和 Svelte 存储的不同用例。在本章中,我们将介绍 Svelte 上下文和 Svelte 存储是什么。
我们将讨论何时使用 Svelte 上下文和/或 Svelte 存储,以及选择它们的考虑因素。然后我们将通过一个结合了 Svelte 上下文和 Svelte 存储的示例来继续讨论——一个 Svelte 上下文存储。
到本章结束时,你将熟练使用 Svelte 存储和 Svelte 上下文在你的 Svelte 应用中。你还将了解何时有效地使用它们。
在本章中,我们将涵盖以下主题:
定义 Svelte 上下文和 Svelte 存储
何时使用 Svelte 上下文和 Svelte 存储
使用 Svelte 存储创建动态上下文
定义 Svelte 上下文
当你需要从父组件传递数据到子组件时,你应该首先考虑使用 props:
<Component props={value} />
如果你需要从父组件传递数据到孙组件呢?你可以从父组件通过 props 传递数据到子组件,然后从子组件传递到孙组件:
<!-- Parent.svelte -->
<Child props={value} />
<!-- Child.svelte -->
<script>
export let props;
</script>
<GrandChild props={props} />
如果你需要从父组件传递数据到曾孙组件呢?
你可以遵循与前面代码中类似的过程,通过组件层传递数据以到达曾孙组件。
这种方法被称为 属性钻取。它类似于通过 props 在组件层中钻孔。由于以下原因,在大多数情况下都不推荐这样做:
很难追踪数据的来源。
当你想在子组件中追踪数据的来源时,你可能需要无限地向上追踪通过父组件的多层,跳过不同的 Svelte 组件文件。
这会减慢你的速度,并使你对数据流进行推理变得更加困难。
很难追踪数据流向何方。
通过属性传递到子组件中的数据不应直接由子组件使用,而应通过它传递到其子组件。你将不得不遍历组件层来找出数据最终被使用的地方。
你可能会失去数据的去向,对修改传递下来的数据进行更改的信心会降低。
重新结构组件层次结构很困难。
当你在层之间添加新组件时,你需要确保仍然通过新组件从其父组件传递属性到其子组件。
当你移动组件时,你需要确保通过检查父组件的链来确保子组件仍然能够获取它需要的属性。
注意
考虑到这一点,当组件树小而简单时,即使有缺点,传递属性仍然可能是从父组件传递数据到其子组件的最简单方式。
那么,属性钻取的替代方案是什么?Svelte 上下文。
Svelte 上下文是一种为所有子组件提供数据的方法,无论它们在组件树中的层级有多深。
组件树就像组件的家谱。你有一个位于顶部的父组件,下一级是其子组件,再下一级是子组件的子组件:
图 8.1:组件树图
在前面的组件树图中,左上角的节点表示 setContext 被调用的位置,该节点下的所有阴影节点都可以使用 getContext 访问上下文值。要在组件中设置上下文值,你可以使用 setContext():
<script>
import { setContext } from 'svelte';
setContext("key", value);
</script>
所有子组件及其子组件的子组件都将能够通过 getContext() 读取上下文值:
<script>
import { getContext } from 'svelte';
const value = getContext("key");
</script>
如果你注意到了前面的代码片段,你可能已经注意到我们一直在设置和读取上下文时使用字符串作为上下文键。然而,你可能想知道是否可以使用其他数据类型作为上下文键。让我们来看看这一点。
使用对象作为上下文键
Svelte 上下文的底层机制是什么?
如我之前提到的,Svelte 上下文是使用 JavaScript Map 实现的,这意味着你可以使用键设置和读取 Svelte 上下文中的值,就像使用 JavaScript 地图一样。
这也意味着你可以为 Svelte 上下文设置多个键:
<script>
setContext("item", item);
setContext("order", order);
</script>
你只需要确保使用你设置上下文时相同的键读取它们。
就像之前提到的,Svelte 上下文是使用 JavaScript Map 实现的,Map 的键可以是任何类型,包括函数、对象或任何原始类型;你不必仅限于使用 String 键值:
<script>
const object = {};
setContext(object, value);
</script>
正如 JavaScript Map 的工作方式一样,如果你使用对象设置上下文,那么你需要使用相同的对象实例来从 Svelte 上下文中读取值。
修改上下文值
使用 setContext 和 getContext 时需要注意的一点是,这些函数需要在组件初始化期间调用。阅读第一章 回顾组件初始化是什么。
如果我们可以在组件初始化后调用 setContext,那么这就会引出下一个问题——我们该如何更改上下文值?
看以下代码片段:
<script>
let itemId = 123;
setContext("itemid", itemId);
itemId = 456;
</script>
当在第 3 行调用 setContext 时,我们正在将 itemId 变量的值传递给 setContext 函数。在第 5 行重新分配 itemId 变量不会使上下文值发生变化。
这就是 JavaScript 的工作方式。如果你用一个原始类型的变量调用一个函数,那么变量的值就会被传递进去,而在函数外部重新分配变量不会改变函数内部读取的变量的值。
那么将对象作为上下文值传递怎么样?让我们看看它是如何工作的:
<script>
let item = { id: 123 };
setContext("item", item);
item.id = 456;
</script>
在 JavaScript 中,对象是通过引用传递的。这意味着 Svelte 上下文和 setContext 函数之外的 item 变量引用的是同一个对象。修改对象会修改同一个引用对象,因此当读取 Svelte 上下文时可以看到这些变化:
<script>
const item = getContext("item");
</script>
{item.id}
然而,你可能已经注意到,在你将 {item.id} 渲染到 DOM 上之后,当你在其父组件中修改它时,DOM 中显示的值并没有改变。
这并不意味着 item.id 没有改变。如果你尝试以间隔打印 item.id,你会注意到 item.id 已经改变,但 DOM 中的值保持不变:
const item = getContext("item");
setInterval(() => {
console.log(item.id);
}, 1000);
为什么会发生这种情况?
Svelte 跟踪 Svelte 组件内部的变量突变和重新分配,并记录操作以更新 DOM 以反映这些变化。然而,这意味着组件外部发生的变化不会被跟踪,因此 DOM 不会反映这些变化。
那么,我们应该怎么做才能让 Svelte 意识到组件外部变量的变化?
这就是 Svelte 存储发挥作用的地方。
定义 Svelte 存储
要理解为什么 Svelte 的响应性在 Svelte 组件内受限,我们必须首先了解 Svelte 的响应性是如何工作的。
与一些其他框架不同,Svelte 的响应性在构建时就会工作。当 Svelte 将 Svelte 组件编译成 JavaScript 时,Svelte 会查看每个变量并跟踪变量,以查看变量何时发生变化。
与其跟踪整个应用程序中的所有变量,Svelte 只限制自己一次分析并编译一个文件。这允许 Svelte 并行编译多个 Svelte 组件文件,但也意味着 Svelte 组件不会意识到其他文件中发生的变量变化。
变量变化未被跟踪的常见情况是,当变量定义在单独的文件中并被导入到当前组件时。
在下面的代码片段中,quantity变量是从一个单独的文件中导入的。Svelte 不会跟踪该文件中可能发生的任何对quantity变量的更改:
<script>
import { quantity } from './item';
</script>
<p>Quantity: {quantity}</p>
如果你尝试在 Svelte 组件外部修改变量,那么 Svelte 无法跟踪这一点。因此,Svelte 不知道何时修改变量,因此无法在发生这种情况时更新 DOM。
为了使 Svelte 意识到组件外部的变化并相应地更新 DOM,我们需要在运行时设计一个机制,以便在变量发生变化时通知 Svelte。
对于此,我们可以从观察者模式中汲取灵感。观察者模式是一种设计模式,它允许你定义一个订阅机制,以便在事件发生时通知多个对象。
使用观察者模式
在这里,除了导入quantity变量之外,我还导入了一个subscribe函数,我们将在稍后定义它:
import { quantity, subscribe } from './item';
subscribe函数的想法是这样的,我们可以订阅以了解quantity何时发生变化。
在这里,我们假设subscribe接受一个回调函数,该函数将在quantity发生变化时被调用。回调函数接受一个参数,它给出了quantity的最新值:
import { quantity, subscribe } from './item';
subscribe((newQuantity) => { ... });
因此,现在尽管 Svelte 仍然无法跟踪组件外部的quantity变量的变化,但我们可以使用subscribe函数来告诉 Svelte 何时发生这种情况。
为了说明如何告诉 Svelte 这一点,我们可以定义另一个变量叫做_quantity,它初始化为与quantity相同的值。
每当quantity发生变化时,传递给subscribe函数的回调函数应该使用新的quantity值被调用。我们将利用这个机会将_quantity更新为新quantity值:
<script>
import { quantity, subscribe } from './item';
let _quantity = quantity;
subscribe((newQuantity) => { _quantity = newQuantity; });
</script>
<p>Quantity: {_quantity}</p>
由于_quantity变量是在组件内部定义的,并且我们在组件内部更新变量的值(在_quantity = newQuantity语句中),Svelte 可以跟踪_quantity的更新。由于_quantity变量跟踪quantity变量的变化,你可以看到每当quantity发生变化时,DOM 都会更新。
然而,所有这些都依赖于subscribe函数,该函数会在quantity的值发生变化时调用回调函数。
那么,让我们看看我们如何定义subscribe函数。
定义subscribe函数
定义subscribe函数有多种方式。
在这里,我们将使用一个数组并将其命名为subscribers,以便我们可以跟踪所有使用subscribe调用的函数。然后,当我们尝试更新quantity的值时,我们将遍历subscribers数组以获取每个订阅函数并逐个调用它们:
let subscribers = [];
function subscribe(fn) {
subscribers.push(fn);
}
function notifySubscribers(newQuantity) {
subscribers.forEach(fn => {
fn(newQuantity);
});
}
例如,在这里,我们想将quantity更新为20。为了确保通知订阅者变化,我们同时调用notifySubscribers并传递更新后的值,这样每个subscribers都会收到quantity的最新值的通知:
quantity = 20;
notifySubscribers(quantity);
当你将前面代码中subscribe和notifySubscribers函数的实现与上一节中的 Svelte 组件代码联系起来时,请慢慢来。你会发现每次我们调用notifySubscribers时,传递给subscribe函数的回调函数都会被调用。_quantity将被更新,DOM 中的值也将被更新。
因此,无论你在 Svelte 组件外部修改quantity,只要调用notifySubscribers函数即可。Svelte 会使用quantity的新值更新 DOM 元素,以反映quantity的最新值。
通过观察者模式,我们现在能够定义和更新 Svelte 组件之间的变量。
你会在 Svelte 中看到很多这种模式。Svelte 将subscribe和notifySubscribers的概念封装成一个叫做 Svelte 存储的概念。所以,让我们探索成为 Svelte 存储的含义,以及 Svelte 为 Svelte 存储提供的一等支持。
定义一个 Svelte 存储
Svelte 存储是任何遵循 Svelte 存储约定的对象。
这意味着任何遵循 Svelte 存储约定的对象都可以被称为 Svelte 存储。作为一个 Svelte 存储,它附带一些语法糖和内置函数。
在我们继续前进之前,让我们看看 Svelte 存储约定是什么。
Svelte 存储约定要求一个对象必须有一个subscribe方法和一个可选的set方法:
const store = {
subscribe() {},
set() {},
};
我还没有告诉你subscribe和set方法的特定要求,但我希望你能看到 Svelte 存储约定中的subscribe和set方法与上一节中展示的subscribe和notifySubscribers函数之间的相似性。
但这里似乎缺少了什么。我们应该把价值存储放在哪里,或者从上一节中对应的quantity变量放在哪里?
好吧,存储值不是 Svelte 存储约定的一部分,我们很快就会解释原因。
让我们继续讨论subscribe和set方法的要求:
subscribe方法必须返回一个函数来取消订阅存储。
这允许订阅者停止接收存储最新值的更新。
例如,如果我们使用一个数组来跟踪使用subscribe函数调用的订阅者函数,那么我们可以使用subscribe返回的函数从数组中移除订阅者函数,因为订阅者函数不再需要从存储中接收任何新的更新:
const subscribers = [];
const store = {
subscribe(fn) {
// add the fn to the list of subscribers
subscribers.push(fn);
// return a function to remove the fn from the list of subscribers
return () => {
subscribers.splice(subscribers.indexOf(fn), 1);
};
}
};
当subscribe方法用一个函数被调用时,该函数必须立即和同步地与存储值一起被调用。
如果函数没有立即被调用,则假设存储值为 undefined:
let storeValue = 10;
const store = {
subscribe(fn) {
// immediately call the function with the store value
fn(storeValue);
// ...
},
};
这个要求意味着要从 Svelte 存储中读取存储值,你需要使用 subscribe 方法:
let storeValue;
store.subscribe((value) => {
storeValue = value;
});
console.log(storeValue);
subscribe method is not being called immediately and synchronously, then immediately in the next statement where we console out the value of storeValue, you will see that the value of storeValue remains undefined.
Svelte 存储的 set 方法接收一个新的存储值并返回空值:
store.set(newValue);
set 方法是用来更新存储值的。自然地,我们会实现 set 方法,以便通知所有存储订阅者最新的存储值:
const store = {
// ...
set(newValue) {
// notify subscribers with new store value
for(const subscriber of subscribers) {
subscriber(newValue);
}
},
};
通过这样,我们已经了解了 Svelte 存储合约的要求。在这个过程中,我们也看到了实现 Svelte 存储每个要求的代码片段。将它们组合起来,我们就会得到一个 Svelte 存储。
创建 Svelte 存储是一个如此常见的用例,以至于 Svelte 提供了一些内置函数来帮助我们创建一个。
使用内置函数创建 Svelte 存储
Svelte 提供了一个子包,导出了一些用于 Svelte 存储的内置函数。你可以从 'svelte/store' 包中导入它们。
这里是内置 Svelte 存储函数的列表:
readable() 帮助创建一个可读的 Svelte 存储。由于 Svelte 合约中的 set 方法是可选的,所以可读存储是一个没有实现 set 方法的存储。
要更新存储值,readable() 函数接收一个回调函数,当存储被订阅时,这个回调函数会被调用,并且回调函数会带有一个 set 函数,可以用来更新存储值:
const store = readable(initialValue, (set) => {
// update store value
set(newValue);
});
回调函数中的 set 函数可以被多次调用。在下面的示例中,我们每秒调用一次 set 函数来更新存储值到最新的时间戳:
const store = readable(Date.now(), (set) => {
setInterval(() => {
// update store value to the current timestamp
set(Date.now());
}, 1000);
});
writable() 帮助创建一个可写的 Svelte 存储。这与可读存储类似,但它实现了 set 方法:
const store = writable(initialValue);
store.set(newValue);
derived() 创建一个新的 Svelte 存储,它从现有的存储中派生出来。
在我们讨论创建自定义存储时,我们将在下一章更详细地探讨 derived()。
使用 readable()、writable() 和 derived(),你可以轻松地创建一个新的 Svelte 存储,而无需自己实现 Svelte 存储合约。
因此,我们有内置方法来创建 Svelte 存储,但我们是否有任何内置方法来使用 Svelte 存储?让我们来看看。
自动订阅 Svelte 存储
由于所有 Svelte 存储都遵循 Svelte 存储合约,所以所有 Svelte 存储都有 subscribe 方法,以及可选的 set 方法。我们可以使用 store.subscribe() 方法来订阅最新的存储值,以及使用 store.set() 来更新存储值:
<script>
import { onMount } from 'svelte';
let storeValue;
onMount(() => {
// use `subscribe` to subscribe to latest store value
const unsubscribe = store.subscribe(newStoreValue => {
storeValue = newStoreValue;
});
return () => unsubscribe();
});
function update(newValue) {
// use `set` to update store value
store.set(newValue);
}
</script>
<p>{storeValue}</p>
当在 Svelte 组件中使用 Svelte 存储时,我们只在需要时(通常在我们挂载 Svelte 组件时)订阅 Svelte 存储。在上面的代码片段中,我们通过在 onMount 回调中调用 store.subscribe 方法,在 Svelte 组件挂载后立即订阅存储。
当不再需要时,取消订阅新存储值的变化是很重要的。这通常发生在我们卸载和销毁 Svelte 组件时。在前面的代码片段中,我们在 onMount 回调中返回一个函数,该函数将在组件卸载时被调用。在该函数中,我们调用从 store.subscribe 方法返回的 unsubscribe 函数。
这符合 Svelte 存储契约,其中 store.subscribe 方法必须返回一个函数来取消订阅存储。
在 Svelte 组件中,我们需要记住在 onMount 期间调用 store.subscribe,并记住在 onDestroy 期间调用 unsubscribe 来清理。
这可能会变得冗长,所以 Svelte 提供了一种在 Svelte 组件中自动订阅 Svelte 存储的方法。
当你在 Svelte 组件中有一个引用存储的变量时,你可以自动订阅存储并通过存储变量的 $ 前缀变量名访问存储值。
例如,假设你有一个名为 count 的 Svelte 存储变量,如下所示:
<script>
import { writable } from 'svelte/store';
const count = writable();
</script>
在这种情况下,你可以自动订阅 count Svelte 存储并通过 $count 访问存储值:
<script>
import { writable } from 'svelte/store';
const count = writable();
console.log($count);
</script>
这相当于订阅存储并将最新的存储值赋给 $count 变量。然而,以这种方式做时,你不再需要显式调用 count.subscribe 方法来订阅存储,并调用 unsubscribe 函数来取消订阅。
如果你注意到了代码,你可能已经注意到我们没有声明 $count 变量。然而,它是神奇地可用的,由 Svelte 在构建 Svelte 组件代码时自动声明。
这也假设了每次当你使用以 $ 开头的变量时,没有 $ 前缀的变量被认为是 Svelte 存储。
此外,由于 Svelte 自动声明以 $ 开头的变量,它禁止声明任何以 $ 符号开头的变量名。
如果我给 $ 前缀变量赋一个新的值会发生什么?这样做相当于调用存储的 set 方法:
$count = 123;
// is equivalent to
count.set(123);
因此,既然我们已经了解了 Svelte 上下文和 Svelte 存储,让我们讨论我们应该何时使用 Svelte 上下文和/或 Svelte 存储。
选择 Svelte 上下文和 Svelte 存储之间的区别
Svelte 上下文和 Svelte 存储是为非常不同的用例设计的。
这里简要回顾一下:Svelte 上下文有助于将数据从父组件传递到所有子组件,而 Svelte 存储有助于使多个 Svelte 组件之间的数据变得响应式。
虽然 Svelte 上下文和 Svelte 存储都是为了在 Svelte 组件之间传递数据,但它们是为不同的用例设计的。因此,选择何时使用 Svelte 上下文和 Svelte 存储永远不会是二选一的情况。
你可以使用 Svelte 上下文、Svelte 存储或两者来在 Svelte 组件之间传递相同的数据。
为了决定使用哪一个,我想出了一个 2x2 的决策矩阵:
图 8.2:选择 Svelte 存储、Svelte 上下文或两者的决策矩阵
在这个 2x2 决策矩阵中,有两个维度:本地-全局和静态-响应式。
根据你传递的数据类型,数据应属于四个象限之一。然后,我们可以决定在组件之间传递数据的最佳方式。
因此,让我们更仔细地看看每个维度代表什么。
在本地-全局维度中,我们确定数据是否应该在整个应用程序中具有相同的全局值,或者在不同彼此靠近的组件中具有独立的本地版本。
例如,语言偏好数据属于全局而不是本地。在整个应用程序中通常只有一个语言偏好数据片段,以确保应用程序中的语言偏好一致。
另一方面,图表仪表板中的图表设置可能是本地数据。在同一图表内,多个 Svelte 组件,如图表轴、图表数据和图表网格,共享相同的数据,但不同的图表可能有不同的图表设置。在整个应用程序中并没有单一的数据片段。因此,在这种情况下,它更倾向于在本地-全局维度上的本地化。
如果数据将在整个应用程序中保持全局,则可以在 JavaScript 模块中声明数据,并在应用程序的任何位置导入:
// language-preference.js
export const languagePreference = ...;
另一方面,如果数据将是本地的,则可以将数据声明为 Svelte 上下文。这允许子组件根据组件在组件树层次结构中的位置获取不同的值:
<!-- Chart.svelte -->
<script>
import { setContext } from 'svelte';
setContext('chart', ...);
</script>
<!-- ChartAxis.svelte -->
<script>
import { getContext } from 'svelte';
// chartSettings depending on which chart it falls under
const chartSettings = getContext('chart');
</script>
在静态-响应式维度中,我们确定数据应该是静态的,这意味着它不会在应用程序的生命周期中改变,或者应该是动态的,其中数据的值将随着用户与应用程序的交互而改变。
静态数据的例子可以是应用程序的主题。数据的值可以根据动态条件确定,但一旦在应用程序开始时确定值,该值在整个应用程序生命周期中不会改变。应用程序的主题是这种情况的一个好例子。通常,应用程序的主题在应用程序加载时确定,并且在整个应用程序中保持不变。
另一方面,动态数据的例子可以是图表数据。图表数据是动态的,可以在应用程序的生命周期内进行更改。
如果数据将在整个应用程序生命周期中保持静态,那么可以使用常规 JavaScript 变量声明数据:
let theme = 'dark';
然而,如果数据将是动态的,并且需要在多个组件之间保持响应式,则应将数据声明为 Svelte 存储:
import { writable } from 'svelte/store';
let chartData = writable();
如果我们将这两个维度结合起来,我们得到以下结果:
静态全局:数据在 JavaScript 模块中以正常 JavaScript 变量的形式声明,并导出供 Svelte 组件导入
动态全局:数据在 JavaScript 模块中以 Svelte 存储的形式声明,并导出供 Svelte 组件导入
静态局部:数据以 Svelte 上下文的形式声明,其中正常 JavaScript 变量作为 Svelte 上下文值
动态局部:数据以 Svelte 上下文的形式声明,其中 Svelte 存储作为 Svelte 上下文值
通过这样,我们已经看到了如何单独使用 Svelte 上下文和 Svelte 存储,但在传递 Svelte 组件之间的动态局部数据时,我们同时使用 Svelte 上下文和 Svelte 存储。
那么,如何结合 Svelte 上下文和 Svelte 存储呢?让我们来看看。
使用 Svelte 存储传递动态上下文
要使 Svelte 上下文数据在组件之间动态且具有反应性,我们需要传递一个 Svelte 存储作为 Svelte 上下文数据,而不是一个正常的 JavaScript 变量。
这与从 JavaScript 模块导入 Svelte 存储非常相似,只是我们不是导入 Svelte 存储;相反,我们通过 Svelte 上下文发送 Svelte 存储。
首先,我们创建一个 Svelte 存储,并将 Svelte 存储传递到上下文中:
<script>
import { writable } from 'svelte/store';
import { setContext } from 'svelte';
// declare a Svelte store
let data = writable(0);
setContext('data', data);
</script>
注意,我们直接将存储传递到 Svelte 上下文中,而不是传递存储值。
在子组件中,我们可以通过getContext()从上下文中读取值:
<script>
import { getContext } from 'svelte';
const data = getContext('data');
</script>
由于data是一个 Svelte 存储,我们可以使用带$前缀的变量来引用 Svelte 存储值:
<script>
import { getContext } from 'svelte';
const data = getContext('data');
</script>
<p>{$data}</p>
要测试反应性是否工作,我们可以在父组件中将新的值设置到data存储中:
<script>
let data = writable(0);
setContext('data', data);
function update() {
$data = 123;
}
</script>
这两个方向都适用。如果你尝试从子组件更新存储值,父组件中的存储值也会更新。由于你通过上下文在所有子组件中获取相同的存储值,因此使用相同存储的任何组件都会更新。
摘要
在本章中,我们学习了 Svelte 上下文和 Svelte 存储的概念。
虽然 Svelte 上下文和 Svelte 存储都旨在在多个 Svelte 组件之间共享数据,但它们的设计和使用目的不同。
Svelte 上下文旨在在组件树中的所有后代组件之间共享相同的数据,而 Svelte 存储旨在在 Svelte 组件之间共享反应性。
然后,我们探讨了何时使用 Svelte 上下文、何时使用 Svelte 存储以及何时同时使用两者的决策矩阵。
本章介绍了 Svelte 上下文和 Svelte 存储。到目前为止,你应该对它们是什么以及它们的工作方式有了很好的理解,并且对何时使用它们感到自信。随着我们继续前进,我们将探讨涉及 Svelte 上下文和 Svelte 存储的实际用例,使你能够在现实世界的场景中有效地应用这些强大的概念。
在下一章中,我们将更深入地探讨 Svelte store 主题,并查看如何创建一个自定义的 store。
第九章:实现自定义存储
在上一章中,我们了解到 Svelte 存储是任何遵循 Svelte 存储合约的对象,将数据封装在 Svelte 存储中允许数据在多个 Svelte 组件之间反应性地共享和使用。Svelte 组件会根据数据更新 DOM,即使数据是在 Svelte 组件外部被修改的。
我们学习了 Svelte 创建 Svelte 存储的两种内置方法——即 readable() 和 writable(),它们创建一个可读和可写的存储。这两种方法遵循 Svelte 合约并创建一个非常基础的 Svelte 存储。然而,除了使用 Svelte 存储封装数据外,我们还可以封装与数据相关的逻辑,使 Svelte 存储模块化且高度可重用。
在本章中,我们将创建自定义 Svelte 存储——封装自定义数据逻辑的 Svelte 存储。我们将通过三个不同的示例进行讲解;每个示例都将作为指南,充满创建您自己的自定义 Svelte 存储的技巧和窍门。
首先,我们将探讨如何将用户事件转换为存储值,特别是将点击次数转换为 Svelte 存储。
其次,我们将探讨一个超出基本 set 方法以修改其值的自定义存储。我们将查看一个撤销/重做存储,它包含用于撤销或重做对其值更改的额外方法。
最后,我们将关注高级存储。虽然高级存储本身不是一个自定义存储,但它是一个接受 Svelte 存储作为输入并返回其增强版本的函数。本章包括以下主题的章节:
从用户事件创建 Svelte 存储
创建一个撤销/重做存储
创建一个防抖的高级 Svelte 存储
因此,无需多言,让我们深入探讨创建我们的第一个自定义 Svelte 存储。
技术要求
本章中所有的代码都可以在 GitHub 上找到:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter09
从用户事件创建 Svelte 存储
Svelte 存储存储数据,但数据从何而来?
它可能来自用户交互或用户输入,这会调用一个事件处理器,通过调用 store.set() 来更新存储值。
如果我们能够将用户事件和事件处理器逻辑封装到存储中,这样我们就不需要手动调用 store.set() 会怎样?
例如,我们将有一个 Svelte 存储来计算用户在屏幕上点击的次数。如果我们能够创建一个 Svelte 存储,并在每次有新的点击时更新它,而不是手动在文档上添加事件监听器,那会是什么样子?简而言之,有没有一个可以为我们做所有这些的自定义 Svelte 存储?
如果我们能够在下一次有类似需求时重用这个 Svelte 存储,而不是再次手动设置它,那将是非常棒的。
因此,让我们尝试实现这个点击计数自定义 Svelte store。
让我们首先构建 Svelte store:
const subscribers = [];
let clickCount = 0;
const store = {
subscribe: (fn) => {
fn(clickCount);
subscribers.push(fn);
return () => {
subscribers.splice(subscribers.indexOf(fn), 1);
}
},
}
在前面的代码片段中,我们创建了一个基于 Svelte store contract 的基本 Svelte store,它只有一个subscribe()方法。store 的值存储在一个名为clickCount的变量中,在subscribe()方法中,我们使用subscribers数组跟踪所有订阅者。
注意,我们需要在subscribe()方法中同步调用fn与 store 的值;这样可以让订阅者知道 store 的当前值。
如果你现在在 Svelte 组件中使用这个 store(如下面的代码片段所示),你会在屏幕上看到0。这就是此时 store 的当前值:
<script>
import { store } from './store.js';
</script>
{$store}
在前面的代码片段中,我们将store导入到 Svelte 组件中,并使用$store显示 store 的值。现在,让我们监听click事件以更新 store 的count值。我们不应该在程序开始时监听click事件,而应该只在存在订阅者时开始订阅。
下面是更新后的代码:
const store = {
subscribe: (fn) => {
// ...
document.addEventListener('click', () => {
clickCount++;
// notify subscribers
subscribers.forEach(subscriber => subscriber(clickCount));
});
},
};
在前面的代码片段中,我们在subscribe方法内部添加了click事件监听器,每当点击document时,我们就将clickCount的值增加1,并通知所有订阅者clickCount的最新值。
如果你现在尝试在多个 Svelte 组件中使用这个 Svelte store,你会意识到每次点击,store 的值都会增加多于一个。
确保事件监听器只添加一次
为什么点击一次时 store 的值会增加多于一次?
如果你仔细查看当前实现,你会意识到我们在每次subscribe方法调用时都调用了document.addEventListener。当你使用 Svelte store 在多个 Svelte 组件中时,每个 Svelte 组件都会单独订阅 store 的变化。如果有五个组件订阅了 store,那么五个click事件监听器将被添加到document上。结果,document上的单次点击将触发五个事件监听器,导致clickCount的值每次增加五。这意味着每次点击,store的值都会增加多于一个。为了修复这种行为,我们仍然可以在subscribe方法中调用addEventListener,但我们需要确保只调用一次,即使subscribe方法可能被多次调用。
我们可以使用一个标志来指示是否已经调用了addEventListener,并确保当标志设置为true时不再调用addEventListener,如下所示:
let called = false;
const store = {
subscribe: (fn) => {
// ...
if (!called) {
called = true;
document.addEventListener('click', () => {... });
}
},
};
在前面的代码片段中,我们添加了一个名为called的变量,并使用它来防止document添加多于一次的点击事件监听器。
这可以工作,但还有更好的实现方式。
我们不需要设置一个新的标志来指示是否已经调用过 addEventListener,我们可以使用任何现有的变量来确定是否应该调用 addEventListener。我们知道一旦调用 subscribe 方法,就应该调用 addEventListener,而在我们添加更多订阅者时不应随后再次调用;我们可以使用 subscribers 数组的长度来确定是否应该调用 addEventListener 方法。
如果目前没有订阅者,那么我们知道这是第一次调用 subscribe 方法。在这种情况下,我们应该调用 addEventListener。另一方面,如果存在现有订阅者——即 subscribers 的长度大于零,我们知道 subscribe 方法之前已经被调用过,因此我们不应再次调用 addEventListener。
因此,以下是更新后的代码,使用 subscribers 数组的长度而不是变量来确定是否应该向文档添加点击事件监听器:
const store = {
subscribe: (fn) => {
// ...
if (subscribers.length === 0) {
document.addEventListener('click', () => {... });
}
},
};
在前面的代码片段中,我们将 !called 条件替换为 subscribers.length === 0。
现在我们已经为订阅我们的 Svelte 存储时添加了点击事件监听器,当所有订阅者从 Svelte 存储中取消订阅时,我们需要进行清理。
为了进行清理,我们将调用 removeEventListener 来从 document 中移除 click 事件监听器。unsubscribe 函数可以被多次调用,但我们应该只在 subscribers 数组中没有更多订阅者时调用 removeEventListener。
const store = {
subscribe: (fn) => {
return () => {
subscribers.splice(subscribers.indexOf(fn), 1);
if (subscribers.length === 0) {
document.removeEventListener('click', () => {...});
}
};
},
};
在前面的代码片段中,subscribe 方法的 return 函数用于取消存储的订阅。在函数中,我们添加了一个检查来查看 subscribers 的数量是否下降到零;如果是这样,我们将从 document 中移除 click 事件监听器。
当你创建一个 Svelte 存储时,通常需要跟踪 subscribers 列表,确保你只设置一次事件监听器,并且在没有更多订阅者后进行清理。
正如你在本节和前一节中看到的,我们已经经历了很多步骤来从用户事件创建 Svelte 存储管理 subscribers 数组,并决定何时向文档添加或移除点击事件监听器。在下一节中,我们将探索使用 Svelte 的内置方法和内置 readable() 函数以更简单的方式实现相同目标的方法。
Svelte 提供了内置方法,如 readable(),以使我们在创建 Svelte 存储时生活更加轻松。
由于存储值只由 click 事件更新,而不是其他地方,所以 readable() 在两个最重要的方法——readable() 和 writable() 中——对于我们的用例来说已经足够好了。
我们将使用readable()函数来创建我们的点击事件存储。readable()函数有两个参数。第一个参数是存储的初始值。readable()函数的第二个参数接受一个函数,该函数在第一个订阅者订阅时会被调用,而对于后续的订阅者则不会被调用。如果该函数返回另一个函数,则当最后一个订阅者从存储中取消订阅时,返回的函数会被调用。这对于我们在document上添加和移除click事件监听器来说非常完美。
让我们看看使用readable重写的更新后的代码:
let clickCount = 0;
const store = readable(clickCount, (set) => {
const onClick = () => set(++clickCount);
document.addEventListener('click', onClick);
return () => {
document.removeEventListener('click', onClick);
};
});
在前面的代码片段中,我们利用readable()来处理click事件监听器的创建和清理,使我们的代码更简洁、更高效。
与上一节中我们自行管理subscribers列表的实现相比,你可以看到使用readable()如何使我们能够从手动维护subscribers数组中解脱出来,并专注于实现逻辑。
了解 Svelte 合约以及如何从头开始实现 Svelte 存储是很好的。但在大多数实际场景中,从readable()或writable()创建 Svelte 存储更容易,并将细节留给内置函数。
现在你已经学会了如何从点击事件创建 Svelte 存储,让我们通过一个练习来练习一下。
练习
让我们开始我们的练习,我们将实现一个 Svelte 存储,其值来自文档的滚动位置:
<script>
const scrollPosition = createStore();
function createStore() {
// Your code here
}
</script>
Scroll position {$scrollPosition}
在代码片段中,使用名为createStore()的函数设置了一个scrollPosition存储。你的任务是实现createStore()函数以实际创建一个scrollPosition存储。
你可以在以下链接找到解决方案:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter09/02-exercise-scroll-position
现在我们已经看到了如何创建一个存储值来自事件的 Svelte 存储,让我们看看另一种类型的 Svelte 存储。
创建撤销/重做存储
通常,我们使用set方法来更改存储的值。然而,我们接下来要探索的下一个自定义 Svelte 存储提供了额外的自定义方法来更新其存储值。我们将要查看的下一个自定义 Svelte 存储是一个撤销/重做存储。它类似于可写存储,你可以订阅并设置新的存储值。但撤销/重做存储还提供了两个额外的函数,undo和redo,它们根据存储值的历史记录将存储值向前或向后回退。
这里是一个如何使用撤销/重做存储的示例:
<script>
let value = createUndoRedoStore();
$value = 123;
$value = 456;
$value = 789;
value.undo(); // $value now goes back to 456
value.undo(); // $value now goes back to 123
value.redo(); // $value now turns to 456
</script>
Value: {$value}
在提供的代码片段中,createUndoRedoStore()函数生成一个撤销/重做存储。最初,我们将存储的值设置为123,然后更新为456,接着是789。当我们调用存储的undo方法时,值会回滚到456,然后是123。随后,使用redo方法将存储的值再次恢复到456。现在我们了解了撤销/重做存储的工作原理,那么在 Svelte 中如何创建一个这样的存储呢?
首先,撤销/重做存储将拥有四个方法:subscribe、set、undo和redo。subscribe和set方法基于 Svelte 存储合约,这也是为什么撤销/重做存储被认为是 Svelte 存储的原因。另一方面,undo和redo方法是两个我们定义的额外方法。
一个 JavaScript 对象可以包含不同的方法和属性,但只要它有subscribe和set方法,并且方法签名遵循 Svelte 存储合约,我们就认为该对象是 Svelte 存储。你可以使用以$前缀的变量来自动订阅 Svelte 存储并引用 Svelte 存储的值。
现在,为了实现这个撤销/重做存储,我们知道如果没有撤销/重做功能,存储的行为就像一个可写存储。因此,我们将基于可写存储实现撤销/重做存储,如下面的代码片段所示:
function createUndoRedoStore() {
const store = writable();
return store;
}
在前面的代码片段中,我们为createUndoRedoStore()函数设定了场景。我们首先使用 Svelte 的writable()函数创建一个可写存储,这将是我们的撤销/重做存储的基础。
但由于我们正在将新值设置到撤销/重做存储中,我们需要跟踪存储值的记录,以便我们可以撤销或重做它们。
为了做到这一点,我们将拦截可写存储的set方法,如下所示:
function createUndoRedoStore() {
const store = writable();
function set(value) {
store.set(value);
}
return {
subscribe: store.subscribe,
set: set,
};
}
在前面的代码中,我返回了一个新的对象。虽然subscribe方法与原始可写存储的subscribe方法相同,但set方法现在是一个新的函数。我们仍然在set函数中调用可写存储的set方法,所以行为变化不大。
但现在,当我们调用撤销/重做存储的set方法时,我们首先调用set函数,然后再将这个调用传递给底层可写存储的set方法。这允许我们在set函数中添加额外的逻辑,该逻辑将在我们设置撤销/重做存储的新值时运行。
在我们急于求成之前,别忘了我们还需要在撤销/重做存储中添加两个更多的方法,即undo和redo。下面是如何实现这一点的说明:
function createUndoRedoStore() {
const store = writable();
function set(value) { store.set(value); }
function undo() {}
function redo() {}
return {
subscribe: store.subscribe,
set: set,
undo: undo,
redo: redo,
};
}
在前面的代码片段中,我们向createUndoRedoStore()返回的对象中添加了两个额外的方法,即undo和redo。我们将继续在接下来的步骤中实现这些方法。现在,我们已经拥有了撤销/重做存储的基本结构。
你可以将前面的代码视为创建自定义 Svelte 存储的模板。我们使用可写存储作为基础并返回一个新的对象。返回的对象被视为 Svelte 存储,因为通过拥有 subscribe 和 set 方法,它遵循 Svelte 存储契约。如果我们想向 subscribe 或 set 方法添加逻辑,我们可以基于可写存储的原始 subscribe 和 set 方法构建一个新的函数。除此之外,我们还可以向返回的对象添加更多方法。
实现撤销/重做逻辑
现在,为了实现撤销/重做逻辑,我们正在创建两个数组,undoHistory 和 redoHistory,以记录我们可以在我们调用 undo() 或 redo() 时回放的价值历史。
每当调用 set 函数时,我们将值作为一个新条目添加到 undoHistory 中,这样我们就可以在调用 undo() 时稍后回放它。当调用 undo() 时,我们将 undoHistory 中的最新条目推入 redoHistory 中,这样我们就可以重做我们刚刚撤销的操作。
让我们继续实现我们刚刚描述的逻辑:
function createUndoRedoStore() {
const store = writable();
const undoHistory = [];
const redoHistory = [];
function set(value) {
undoHistory.push(value);
redoHistory.length = 0; // resets redoHistory
store.set(value);
}
function undo() {
if (undoHistory.length <= 1) return;
redoHistory.push(undoHistory.pop());
store.set(undoHistory[undoHistory.length – 1]);
}
function redo() {
if (redoHistory.length === 0) return;
const value = redoHistory.pop();
undoHistory.push(value);
store.set(value);
}
// ...
}
在前面的代码片段中,我们使用两个数组:undoHistory 和 redoHistory 实现了 undo 和 redo 函数。我们在执行撤销或重做操作之前还添加了检查,以查看这些数组中是否有任何值。这确保了我们不会在没有历史记录可以回滚或前进的情况下尝试撤销或重做。因此,你现在已经学会了如何创建一个从可写存储扩展并添加新行为到原始 set 方法,以及向存储添加新方法的自定义 Svelte 存储。
是时候进行练习了。
练习
现在我们已经学会了如何创建一个提供自定义方法来操作底层存储值的自定义存储,让我们通过一个练习来构建另一个这样的自定义存储,一个缓动存储。缓动存储是一个只能包含数值的 Svelte 存储。当你设置缓动 Svelte 存储的值时,缓动 Svelte 存储会花费一个固定的持续时间来更新其存储值到设置的值。
例如,让我们假设缓动 Svelte 存储最初设置为 0:
const store = createTweenedStore(0); // $store = 0
然后,你将存储的值设置为 10:
$store = 10;
存储值不是直接设置为 10,而是在可配置的固定持续时间内从 0 增加到 10——比如说,1 秒。
现在你已经学会了缓动存储的行为,让我们实现一个缓动 Svelte 存储。你可以在 GitHub 上找到缓动存储的代码,链接为 github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter09/04-exercise-tweened-store。
现在我们已经学会了如何创建自定义存储,让我们将注意力转向另一个概念,一个高阶存储——一个接受 Svelte 存储作为输入并返回基于该输入的更专业、更自定义存储的函数。
创建一个防抖高阶 Svelte 存储
我们到目前为止看到的两个先前的部分各自创建了一个新的 Svelte 存储。在本节中,我们将探讨如何创建一个高阶存储。
高阶存储的概念灵感来源于高阶函数,其中函数被当作其他数据一样对待。这意味着你可以将函数作为参数传递给其他函数,或者将它们作为值返回。
在类似的概念下,我们将创建一个函数,将存储当作任何数据一样对待,接受一个 Svelte 存储作为参数,然后返回一个 Svelte 存储。
高阶 Svelte 存储的想法是创建一个函数来增强现有的 Svelte 存储。高阶 Svelte 存储是一个函数,它接受一个 Svelte 存储,并返回一个新的 Svelte 存储,这是输入 Svelte 存储的增强版本。
我们将要使用的示例将创建一个 debounce 高阶 Svelte 存储。
我将要创建的 debounce 函数将接受一个 Svelte 存储,并返回一个新的 Svelte 存储,其存储值基于输入 Svelte 存储值进行防抖:
<script>
function debounce(store) { ... }
const store = writable();
const debouncedStore = debounce(store);
</script>
Store value: {$store}
Debounced store value: {$debouncedStore}
在前面的代码片段中,第四行展示了我们将在此部分实现 debounce 函数的使用方法。这个 debounce 函数接受一个 Svelte 存储作为参数,并返回其增强版本,我们将其称为 debouncedStore。为了展示防抖功能,原始 store 和 debouncedStore 参数的值并排显示。
在开始实现 debounce 函数之前,让我们谈谈什么是防抖。
防抖存储值变化
在工程学中,防抖是从用户输入中移除不想要的输入噪声的过程。在 Web 开发中,当有太多用户事件时,我们会使用防抖,只在用户事件稳定后触发事件处理器或处理事件。
这里有一个防抖的例子。在实现自动完成搜索时,我们不想在用户输入每个字符时都触发自动完成结果的搜索;相反,我们只在用户停止输入后才开始搜索。这样可以节省资源,因为当用户输入下一个字符时,自动完成结果可能就不再可用。
将防抖的概念应用于 Svelte 存储,我们将创建一个新的基于输入 Svelte 存储的防抖 Svelte 存储。当输入 Svelte 存储的值更新时,防抖 Svelte 存储仅在 Svelte 存储更新稳定后才会更新。
如果我们要从头开始创建一个延迟的 Svelte 存储,我们可以基于可写存储构建它。我在上一节中展示了如何从可写存储创建自定义 Svelte 存储,所以我希望你知道如何做。请亲自尝试一下;当你完成时,将你的实现与我提供的实现进行比较,请参阅github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter09/05-debounce-store。
但在本节中,我们将创建一个高阶存储。我们已经有了一个 Svelte 存储。它可能是一个可写存储或撤销/重做存储。我们的延迟函数将接受 Svelte 存储,并返回一个延迟版本的 Svelte 存储。
让我们从 debounce 高阶存储的基本结构开始。
debounce 函数将返回一个新的 Svelte 存储。基于可写存储构建一个新的 Svelte 存储比从头开始实现它、创建一个订阅函数和维护订阅者数组要容易得多。
下面是 debounce 函数的基本框架:
function debounce(store) {
const debounced = writable();
return {
subscribe: debounced.subscribe,
set: store.set,
};
}
在前面的代码片段中,返回的 Svelte 存储将基于可写存储,因此 subscribe 方法将是可写存储的 subscribe 方法。
set 方法将是原始存储的 set 方法,而不是一个新的 set 函数。我们不会创建一个单独的 set 函数,在其中设置原始存储并尝试使用延迟逻辑设置一个延迟的存储。
我们不会像这个片段中那样做:
function debounce(store) {
const debounced = writable();
function set(value) {
store.set(value);
// some debounce logic and call debounce.set(value);
}
return {
subscribe: debounced.subscribe,
set: set,
};
}
在前面的代码片段中,我们拦截了 set 方法并将其转发到原始存储和延迟存储。我们不会这样做,因为我们想保留原始 Svelte 存储的逻辑。当一个值传递给 set 方法时,它可能会经历转换,特别是如果存储是自定义 Svelte 存储。set 方法中的 value 参数可能不等于最终的存储值。因此,我们让原始存储处理这些潜在的转换,并订阅原始 Svelte 存储以获取最终的存储值。我们使用最终的存储值,并在原始存储值变化时更新延迟存储,如下面的代码片段所示:
function debounce(store) {
const debounced = writable();
store.subscribe(value => {
// some debounce logic and call debounce.set(value);
});
// ...
}
在前面的代码片段中,我展示了如何订阅原始 Svelte 存储而不是拦截其 set 方法。这种方法允许我们保持原始存储的逻辑完整,同时仍然受益于其功能。它还处理了原始 Svelte 存储值可能通过其他方法更改的情况。这种情况的一个例子是撤销/重做存储的 undo() 和 redo() 方法。如果我们只拦截 set 函数的值,就像前面的代码片段中那样,那么当原始撤销/重做存储正在撤销或重做时,延迟的 Svelte 存储不会改变。
为了实现去抖逻辑,我们将使用一个超时来更新去抖动的 Svelte 存储库。如果在超时期间原始 Svelte 存储库有新的变化,我们将取消之前的超时并设置一个新的超时。如果没有,那么我们假设变化已经稳定,我们将更新debounced存储库。
这是带有去抖逻辑的更新代码片段:
function debounce(store) {
const debounced = writable();
let timeoutId = null;
store.subscribe(value => {
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
debounced.set(value);
}, 200);
});
// ...
}
在前面的代码片段中,我们在subscribe回调函数中使用setTimeout函数设置了一个 200 毫秒的超时。在这段时间内,如果原始存储库的值再次变化,现有的超时将被清除,并设置一个新的超时。但如果在 200 毫秒的时间段内有新值,我们将使用原始存储库的最新值更新debounced存储库。
尝试使用debounced存储库,并查看存储库的值现在已从变化中去抖动。
你可能会注意到,我们订阅了原始的 Svelte 存储库,但还没有取消订阅它。让我们接下来解决这个问题。
根据需要订阅和取消订阅原始存储库
在我们结束之前,我们实现到目前为止的debounced存储库有一个小问题。我们在创建debounced Svelte 存储库时订阅了原始的 Svelte 存储库,但我们从未取消订阅它,即使debounced Svelte 存储库可能不再被使用。我们应该只在debounced存储库已经有订阅者时才开始订阅原始的 Svelte 存储库,并在没有订阅者时取消订阅。
我们可以拦截subscribe方法,并在去抖动的 Svelte 存储库被取消订阅时尝试取消订阅原始的 Svelte 存储库,如下所示:
function debounce(store) {
const debounced = writable();
function subscribe(fn) {
const debouncedUnsubscribe = debounced.subscribe(fn);
const unsubscribe = store.subscribe(...);
return () => {
debouncedUnsubscribe();
unsubscribe();
};
}
// ...
return {
subscribe: subscribe,
set: store.set,
};
}
在更新的代码片段中,subscribe方法现在同时订阅了debounced存储库和原始 Svelte 存储库。在用于取消订阅debounced存储库的return函数中,我们也确保取消订阅原始 Svelte 存储库。
然而,这并不完全正确。这样做意味着每次我们订阅去抖动存储库时,我们都会订阅原始存储库,每次我们取消订阅去抖动存储库时,我们都会取消订阅原始存储库。
我们应该旨在只订阅原始存储库一次,无论有多少订阅者。同样,只有当没有订阅者剩下时,我们才应该取消订阅它。这听起来熟悉吗?
在上一节中,当我们尝试实现一个点击计数器 Svelte 存储库时,我们遇到了一个类似的难题。在我们发现 Svelte 提供了一个内置的writable()函数可以很好地处理这个问题之前,我们试图维护一个订阅者列表。
那么,有没有一个内置函数允许我们在订阅另一个存储库时只订阅一次,并且只有当存储库中没有更多订阅者时才取消订阅该存储库?
是的——这正是derived()发挥作用的地方。
使用 derived() 函数派生新的 Svelte 存储对象
derived() 是 Svelte 中的一个内置函数,它接受一个或多个 Svelte 存储并返回一个新的 Svelte 存储对象,其存储值基于输入的 Svelte 存储对象。
例如,你可以使用 derived() 定义一个 Svelte 存储对象,其存储值总是另一个 Svelte 存储值的两倍,如下面的代码片段所示:
import { writable, derived } from 'svelte/store';
const store = writable(1);
const double = derived(store, value => value * 2);
或者,这也可以是一个 Svelte 存储对象,其存储值是两个 Svelte 存储对象之和,如下所示:
const store1 = writable(1);
const store2 = writable(2);
const sum = derived(
[store1, store2],
([$store1, $store2]) => $store1 + $store2
);
注意
你可能会注意到,我们在 derived() 函数的回调函数中使用了一个以 $ 前缀的变量;例如,$store1 和 $store2。然而,它的工作方式与脚本顶层的 $ 前缀变量不同,后者会自动订阅存储并作为存储值引用。
在这种情况下,这仅仅是一些人用来表示变量用于引用存储值的一种约定。它与任何其他变量名一样,没有更多。
当使用 derived() 时,Svelte 只在返回的 Svelte 存储对象(例如,sum)被订阅时才会订阅输入的 Svelte 存储对象(例如,store1 和 store2),当没有更多订阅者时,将取消订阅输入的 Svelte 存储对象。
在我们使用 derived() 重新编写 debounce 高阶存储之前,让我们先更深入地了解 derived() 函数。
derived() 函数提供了两种确定返回的 Svelte 存储存储值的方法:同步或异步。
同步方法意味着只要输入 Svelte 存储对象的值发生变化,返回的 Svelte 存储对象的存储值就会同步确定。同样,异步方法意味着它是异步确定的,这意味着存储值可以在输入 Svelte 存储对象的值改变后稍后设置。
我在本节开头展示的两个例子使用了同步方法,其中返回的 Svelte 存储的存储值是在输入 Svelte 存储值改变后立即同步计算并设置的。
当返回的 Svelte 存储对象的存储值通过某些异步操作确定时,异步方法可能很有用。
要确定派生的存储对象是使用同步方法还是异步方法,derived() 方法会查看回调函数的函数签名。如果它接受一个参数,则被认为是同步方法。如果它接受两个参数,则被认为是异步方法。为了更好地理解,请查看以下代码片段:
derived(store, ($store) => ...) // synchronous
derived(store, ($store, set) => ...) // asynchronous
在这两种情况下,回调函数将在任何输入 Svelte 存储对象的值发生变化时被调用。
如果派生的存储对象使用同步方法,回调函数的返回值将用作返回的 Svelte 存储对象的新存储值。
如果派生存储使用异步方法,则回调函数的第二个参数是一个 set 函数。set 函数可以用来在任何时候设置返回存储的值。然而,回调函数的返回值将被视为一个清理函数,它将在再次调用回调函数之前立即被调用。
现在我们对 derived() 函数更熟悉了,让我们使用 derived() 重新编写我们的 debounce 高阶存储。
使用派生方法
这是使用 derived() 方法重写的 debounced 存储的更新代码:
function debounce(store) {
const debounced = derived(store, (value, set) => {
let timeoutId = setTimeout(() => {
timeoutId = null;
set(value);
}, 200);
return () => {
if (timeoutId !== null) clearTimeout(timeoutId);
};
});
return {
subscribe: debounced.subscribe,
set: store.set,
};
}
我们不是使用 writable() 创建一个新的 debounced 存储,而是使用 derived() 方法从原始存储中派生它。我们将 setTimeout 函数移入 derived() 方法回调中。当原始 Svelte 存储有新值时,我们将清除超时并设置一个新的超时。
derived() 函数将在订阅 debounced 存储时仅订阅一次原始 Svelte 存储,并在没有更多订阅者时取消订阅原始 Svelte 存储。
由于我们将在超时后异步更新 debounced 存储,我们将带有两个参数的回调函数传递给 derived() 函数。我们调用 set 函数在超时后设置 debounced 存储的值。如果原始 Svelte 存储的值在超时之前发生变化,则回调函数返回的函数将在再次使用原始 Svelte 存储的更新存储值调用回调函数之前首先被调用。在返回的函数中,我们清除超时,因为我们不再需要它了。
在我们总结之前,还有最后一件事——有时,原始 Svelte 存储可能包含额外的函数,例如在撤销/重做存储中的 undo() 和 redo() 方法。这些方法也应该在我们的高阶函数返回的 debounced 存储中定义。这确保了增强的存储在添加防抖功能的同时,保持了所有相同的方法和行为。你可以在下面的代码片段中看到这个示例:
const value = createUndoRedoStore();
const debouncedValue = debounce(value);
$debouncedValue = 123;
$debouncedValue = 456;
debouncedValue.undo(); // $debouncedValue reverts to 123
要返回原始 Svelte 存储的所有方法,我们使用扩展运算符 (...) 来扩展原始 Svelte 存储的所有方法:
function debounce(store) {
const debounced = derived(...);
return {
...store,
subscribe: debounced.subscribe,
};
}
在这种情况下,我们甚至不需要 set: store.set,因为 set 方法也会扩展到返回的 Svelte 存储中!
就这些了!在本节中,你学到了另一个技巧——创建一个高阶存储,这是一个接受现有 Svelte 存储并返回一个具有新行为的新的 Svelte 存储的函数。我们不再需要构建一个包含所有逻辑的自定义 Svelte 存储,现在我们可以创建更小、封装良好的高阶存储,并将它们放入你需要的自定义 Svelte 存储中。
练习
现在,是时候进行练习了。
上一节中的撤销/重做存储被创建为一个自定义的 Svelte 存储。你能创建一个撤销/重做高阶存储,将任何 Svelte 存储转换为具有两个额外方法undo()和redo()的撤销/重做存储吗?
下面是一个撤销/重做高阶存储的使用示例:
import { writable } from 'svelte/store';
const originalStore = writable(100);
const undoRedoStore = undoRedo(originalStore);
$undoRedoStore = 42;
undoRedoStore.undo(); // store value now goes back to 5
上述代码片段展示了我们如何使用撤销/重做高阶存储undoRedo。undoRedo函数接收一个存储,命名为originalStore,并返回一个基于originalStore的新存储,该存储具有撤销/重做功能。例如,如果我们设置了一个新值然后调用undo方法,存储的值将恢复到其原始状态,在这个例子中是5。
您可以在 GitHub 上找到这个练习的解决方案:
github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter09/07-exercise-undo-redo-higher-order-store
在本节中,我们探讨了使用derived()方法创建debounce高阶 Svelte 存储的过程。我期待你在实际场景中应用这些知识。
摘要
在本章中,我们研究了三个不同的示例,并学习了使用 Svelte 存储的三个不同技术。
我们探讨了如何将任何用户事件转换为 Svelte 存储的数据源,学习了如何从头创建 Svelte 存储,还学习了如何使用内置的readable()函数使过程变得更加简单。
之后,我们探讨了创建一个带有额外方法的自定义 Svelte 存储,基于内置的可写存储构建一个新的 Svelte 存储。
最后,我们学习了如何创建一个高阶存储,这是一个接收 Svelte 存储并返回输入存储增强版本的函数。在示例中,我们看到如何将任何 Svelte 存储转换为自身的防抖版本。
通过理解这些技术,你现在可以更有效地在 Svelte 中管理状态,以构建更可扩展和可维护的应用程序。
在下一章中,我们将探讨 Svelte 中的状态管理——具体来说,如何使用 Svelte 存储进行状态管理。
第十章:使用 Svelte 存储进行状态管理
每个用户界面控件都有一个状态。复选框有一个选中/未选中的状态。文本框的状态是其当前的输入值。表格的状态是显示的数据和当前排序的列。有时,当屏幕上同时存在多个用户界面控件时,你需要同步它们的状态——这就是状态管理的作用所在。
在本章中,我们将讨论使用 Svelte 存储进行状态管理。我们将从为什么应该使用 Svelte 存储开始,然后讨论使用 Svelte 存储进行状态管理时的一些技巧。
接下来,我们将探讨使用状态管理库的主题。我们将讨论为什么以及如何使用它们。在此基础上,我们将通过几个示例展示如何通过 Svelte 存储将第三方状态管理库集成到 Svelte 中。
本章包括以下主题的章节:
使用 Svelte 存储管理状态
在 Svelte 中使用状态管理库
技术要求
本章中的代码可以在 GitHub 上找到:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter10。
使用 Svelte 存储管理状态
当构建交互式用户界面时,我们首先考虑的是确定表示各种组件和交互所需的状态。
例如,在下面的代码片段中,我有一个包含几个组件的登录表单,包括两个输入框、一个复选框和一个按钮:
<Input name="username" />
<Input name="password" />
<Checkbox label="Show Password" />
<Button>Submit</Button>
每个 Svelte 组件都有多个状态,如下所述:
<Input /> 组件有一个输入值状态和验证期间设置的错误状态
<Checkbox /> 组件有一个选中/未选中的状态,选中时会在输入框中显示密码
密码 <Input /> 组件有一个额外的状态来显示/隐藏密码
<Button /> 组件有一个启用/禁用状态,当表单不完整时禁用
这些状态中的每一个都可以用一个变量来表示。这些变量本身可以是相互关联的。
让我们看看这些变量如何相互关联。例如,当复选框被选中时,密码输入需要显示密码。当姓名和密码输入被填写时,按钮需要变为启用状态。当按钮被按下时,执行验证,并相应地更新输入的错误状态。
在组件之间管理多个状态以形成一个连贯体验的技艺被称为状态管理。
将所有这些状态分组在一起并作为一个整体来管理,会比分别管理单个状态更清晰、更容易。在下面的代码片段中,我将上述所有变量组合成一个变量对象:
const state = {
nameValue: '',
nameError: null,
passwordValue: '',
passwordError: null,
revealPassword: false,
submitDisabled: true,
}
在这里,你可以看到相关状态集中在一个地方,这使得更容易查看并判断是否有错误发生。
为了说明这一点,让我们假设nameValue和submitDisabled变量被保留为两个单独的组件中的两个单独的变量;在Input组件中,我们有nameValue变量:
<!-- Input.svelte -->
<script>
export let nameValue = '';
</script>
<input bind:value={nameValue} />
在Button组件中,我们有一个submitDisabled变量:
<!-- Button.svelte -->
<script>
export let submitDisabled = false;
</script>
<button disabled={submitDisabled}>Submit</button>
从代码中,你可以看到很难直接判断nameValue和submitDisabled变量是否相关。如果某个状态没有按预期更新,你没有一个方便的地方可以同时检查这两个状态;你将不得不在每个单独的组件中检查它们。
例如,如果<Button />组件中的submitDisabled状态在输入名称后没有改变为false以启用按钮,你将需要在一个单独的组件(<Input />组件)中找到并检查nameValue变量。
如果,相反,状态被组合成一个state对象,那么你可以检查state对象并查看nameValue和submitDisabled属性是否设置正确。看看下面的例子:
const state = {
nameValue: '',
submitDisabled: true,
};
既然我们已经确定应该将多个相关状态组合成一个状态对象,接下来的问题是:如果我们可以使用简单的 JavaScript 对象来组合状态,为什么还需要使用 Svelte 存储来组合状态呢?
好吧,事实是,这并不总是必要的。在某些情况下,使用简单的 JavaScript 对象来组合状态可以同样有效。然而,Svelte 存储提供了额外的功能和好处,可以增强你的状态管理体验。
正如我们在第八章中探讨的那样,Svelte 存储非常有用,因为它可以在组件之间传递,并且其更新可以在组件之间传播。然后在第九章中,我们看到了 Svelte 存储是多么有用,它能够封装逻辑在 Svelte 存储中,以及能够定义数据逻辑紧挨着数据本身。
因此,为了本章的目的,我们将继续使用 Svelte 存储将多个相关状态组合成一个状态对象。以下是如何看起来的一段简短代码片段:
<script>
import { writable } from 'svelte/store';
const state = writable({
nameValue: '',
nameError: null,
passwordValue: '',
passwordError: null,
revealPassword: false,
submitDisabled: true,
});
</script>
这里,我们使用writable()存储来创建状态对象,因为我们将会修改状态对象。
状态对象可以在 Svelte 组件内部或外部定义。由于我们的状态对象是一个 Svelte 存储,我们可以将状态对象导入到任何组件中,并且任何对状态对象的更新都将传播到所有使用状态对象的 Svelte 组件——所以,状态对象在哪个文件中定义并不重要。
如果我们在 Svelte 组件中定义我们的状态对象,那么我们可以通过 props 将状态对象传递给组件的子组件。以下是一个这样的例子:
<script>
const state = writable({ name: 'Svelte' });
</script>
<Input {state} />
另一方面,如果我们在一个 JavaScript 模块中定义我们的状态对象,那么我们可以将我们的状态对象导入任何将使用状态对象的 Svelte 组件。以下是将 Svelte 存储状态对象导入 Svelte 组件的代码片段:
<script>
// import state object defined in './state.js'
import { state } from './state.js';
</script>
在 第八章 中,我们学习了如何读取、订阅和更新 Svelte 存储的内容。无论存储在 Svelte 存储中的数据是状态对象还是其他任何东西,在读取、订阅和更新 Svelte 存储状态对象的操作上都没有区别,就像任何其他 Svelte 存储一样。
但是,随着状态在增长的项目中使用频率越来越高,你将在 state 存储对象内部拥有更多的状态值。随着 state 存储对象变得更大和更复杂,你的应用程序可能会变慢,你可能会有优化/改进它的需求。因此,我将以下小节专门用于分享我关于使用 Svelte 存储进行状态管理的技巧和观点。
当然,你不应该盲目地应用所有这些方法。正如著名计算机科学家唐纳德·克努特所说,“过早优化是万恶之源”——你应该衡量和评估以下任何一条建议是否适用。随着我介绍这些技巧,我会解释它们是什么,为什么以及何时它们是有用的。
小贴士 1 – 使用单向数据流简化复杂的状态更新
当你的应用程序变得更加复杂,有多个组件和多个更新状态的地方时,管理状态更新变得更加具有挑战性。
你如何知道管理状态更新变得难以控制?你可能会遇到以下情况:
应用程序的状态发生变化,而你发现很难追踪状态变化的原因
你更改了某个状态值,但无意中,这导致其他看似无关的状态值也进行了更新,而你很难找出原因
这些迹象表明状态更新已经变得复杂且难以管理。在这种情况下,拥有单向数据流可以帮助简化状态管理。
使用单向数据流,状态更新以单一方向进行管理,数据流可以轻松追踪和调试。
有一些状态管理库,如 Redux、MobX 和 XState,可以帮助强制执行单一数据流并使你能够对状态变化进行推理。你可能对如何使用这些状态管理库与 Svelte 存储感兴趣;我将在本章的后面部分介绍它们,并将使用其中一个库作为示例。
小贴士 2 – 相比于一个大型的状态对象,更倾向于使用较小的自包含状态对象
In Svelte, when a state object changes, all components that use that state object are updated, even if the specific state value that a component uses did not change. This means that if a state object becomes larger and more complex, with many unrelated state values, updating that state object can trigger unnecessary updates to many components in the application.
Here, I am going to use a product details page of an e-commerce web application as an example. In a typical e-commerce web app, you have a page that shows the details of a product. You will see other information, such as the shopping cart and product reviews, on the same page as well.
If I use one state object for all the information on the page, it may look like this:
<script>
let state = {…}
</script>
<ShoppingCart cart={$state.cart} />
<ProductDetails product={$state.product} />
<ProductRatings ratings={$state.ratings} />
In the preceding code, we use the same state object, $state, across three components: ShoppingCart, ProductDetails, and ProductRatings. When any part of the $state state object changes, such as changing $state.cart, all three components will be triggered to update.
This is undesirable—multiple components updating unnecessarily could lead to slower performance and a less responsive user interface.
So, it is recommended to split big state objects into smaller state objects. Using the same example, that would mean splitting the $state state object into three smaller state objects, like so:
<script>
let cartState = {…};
let productState = {…};
let ratingState = {…};
</script>
<ShoppingCart cart={$cartState} />
<ProductDetails product={$productState} />
<ProductRatings ratings={$cartState, would not trigger an update on the <ProductDetails /> and <ProductRatings /> components.
So, if you have big state objects and you find components update unnecessarily and the performance of your application is impacted by it, then consider breaking these big state objects down into smaller state objects.
But what if the state object is still big, yet the different values in the state objects are closely related and you are unable to break it apart into smaller state objects? Well, there is still hope, which leads us to our third tip.
Tip 3 – derive a smaller state object from a bigger state object
If state values are related to each other and so you can’t break a big state object into smaller state objects, we can create smaller state objects that derive from the big state object. Let me show you some code to explain this clearly.
Let’s say that you have a state object, `userInfo`, that has two closely related state values, `$userInfo.personalDetails` and `$userInfo.socials`, as shown here:
import { writable } from 'svelte/store';
const userInfo = writable({
personalDetails: {...},
socials: {...},
});
You may realize that one part of the `userInfo` state object doesn’t change as often as the other. But whenever any part of `userInfo` changes, all the components that use either the `$userInfo.personalDetails` or `$userInfo.socials` state values will be updated.
To ensure that components using only `$userInfo.socials` are updated exclusively when `$userInfo.socials` changes, one way would be to break the state object into smaller, more focused state objects, like so:
import { writable } from 'svelte/store';
const userPersonalDetails = writable({...});
const userSocials = writable({...});
As you can see, you now have two separate state objects, `userPersonalDetails` and `userSocials`.
But this would mean that places where you previously updated the `userInfo` state object would have to change since `userInfo` is now split into two separate state objects.
Here is how you would change the code:
// previously
$userInfo = newUserInfo;
// now
$userPersonalDetails = newUserInfo.personalDetails;
$userSocials = newUserInfo.socials;
So, the question now is this: Is there an alternative to not having to change this, yet being able to update the components that use `$userInfo.socials` only when `$``userInfo.socials` changes?
I believe I’ve leaked the answer already. The alternative is to derive a new state object. In the following code snippet, I am deriving a new `userSocials` state object from the `userInfo` state object:
import { derived } from 'svelte/store';
const userSocials = derived(userInfo, $userInfo => $userInfo.socials);
The component that uses `$userSocials` will only update whenever the `userSocial` state changes. The `userSocial` state changes only when the `$userInfo.socials` changes. So, when the component uses `$userSocials` instead of `$userInfo.socials`, it will not update when any other part of the `userInfo` state object changes.
I believe that seeing is believing, and it will be much clearer to see and interact with an example to get this idea forward. So, I’ve prepared some demo examples at [`github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter10/01-user-social`](https://github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter10/01-user-social), and you can try them out and see what I mean.
Let’s quickly recap the three tips:
* Whenever the state update logic is complex and convoluted, introduce some state management libraries to enforce simpler and unidirectional data flows
* If the state object gets too big, and state changes update more components than needed, break the state object into smaller state objects
* If you can’t split a Svelte store state object, consider deriving it into a smaller state object
So, we’ve gone through my three general tips for managing complex stores in a Svelte application; let’s now elaborate more on my first tip on how to use state management libraries with Svelte.
Using state management libraries with Svelte
If you google *State management library for frontend development*, at the time of writing, you will get list after list of libraries, such as Redux, XState, MobX, Valtio, Zustand, and many more.
These libraries have their own take on how states should be managed, each with different design considerations and design constraints. For the longevity of the content of this book, we are not going to compare and analyze each of them since these libraries will change and evolve over time and potentially be replaced by newer alternatives.
It is worth noting that some of the state management libraries are written for a specific web framework. For example, at the time of writing, the Jōtai library ([`jotai.org/`](https://jotai.org/)) is written specifically for React, which means you can only use Jōtai if you write your web application in React.
On the other hand, there are framework-agnostic state management libraries. An example is XState ([`xstate.js.org/`](https://xstate.js.org/)), which can be used by any web framework as the XState team has created packages such as `@xstate/svelte` to work with Svelte, `@xstate/react` for the React framework, `@xstate/vue` for Vue, and many more.
While the `@xstate/svelte` package is tailored for seamless integration of XState in Svelte, not all state management libraries offer this level of compatibility. Nevertheless, there are several other state management libraries that you can use in Svelte, and integrating them is straightforward. In fact, I will provide some examples to showcase how simple it is to integrate these libraries in Svelte and utilize Svelte’s first-class capabilities for working with stores.
One such state management library that works seamlessly with Svelte is Valtio ([`github.com/pmndrs/valtio`](https://github.com/pmndrs/valtio)), a minimalist library that turns objects into self-aware proxy states. We are going to explore how we can use Valtio in Svelte, by turning Valtio’s proxy state into a Svelte store and using the Svelte store’s `$`-prefixed syntax to subscribe to the proxy state changes and access the proxy state value.
Example – using Valtio as a Svelte store
Before we start talking about how to use Valtio in Svelte, let’s look at how to use Valtio on its own.
Using Valtio
Valtio turns the object you pass to it into a self-aware proxy state. In the following code snippet, we create a Valtio state named `state` through the `proxy()` method:
import { proxy } from 'valtio/vanilla';
const state = proxy({ count: 0, text: 'hello' });
To update the proxy state, you can make changes to it the same way you would to a normal JavaScript object. For example, we can increment the value of `state.count` by mutating the value directly, as follows:
setInterval(() => {
state.count++;
}, 1000);
To be notified of modifications in the proxy state, we can use Valtio’s `subscribe` method, as illustrated here:
import { subscribe } from 'valtio';
const unsubscribe = subscribe(state, () => {
console.log('state object changed');
});
In this case, every time we modify the state, the callback function passed into the `subscribe` method will be called, and we will see a new log in the console, printing `'state` `object changed'`.
Valtio also allows you to subscribe only to a portion of a state. For example, in the following snippet, we subscribe only to the changes made to `state.count`:
const unsubscribe = subscribe(state.count, () => {
console.log('state count changed');
});
Since in this case, we are subscribing only to the changes made to `state.count`, then modifying `state.text` would not see a `'state count changed'` log added to the console as the change to `state.text` is not subscribed.
The Valtio proxy state is meant for tracking update changes. To read the latest value of the proxy state, we should use the `snapshot()` method to create a snapshot object, as follows:
import { subscribe, snapshot } from 'valtio';
const unsubscribe = subscribe(state, () => {
const obj = snapshot(state);
});
Using Valtio as a Svelte store
Now that we’ve learned about basic Valtio operations and concepts, let’s create a simple counter application to see how we can use Valtio in Svelte. Firstly, we create a proxy state for our counter application:
// filename: data.js
import { proxy } from 'valtio';
export const state = proxy({ count1: 0, count2: 0 });
Here, I am creating two counters, `count1` and `count2`, which will later allow us to experiment with subscribing to a specific portion of the proxy state. This way, we can observe whether our application updates only when one of the counters changes.
Also, we are creating the proxy state in a separate file, `data.js`, rather than declaring it inside a Svelte component; this way, we can import the state separately in each Svelte component later on.
In addition, let’s create two functions to increment the counters:
export function increment1() {
state.count1++;
}
export function increment2() {
state.count2++;
}
Now, let’s import the proxy state into our Svelte component; I have created two buttons to increment each counter separately:
Count #1: {state.count1}
Count #2: {state.count2}
Increment 1
Increment 2
If you try clicking on the buttons, you’ll realize that they are not working as expected— the counters are not incrementing.
However, if you add the following code to `data.js` and click on the button, the console will print out the current value of the counters, indicating that the counters are incrementing successfully:
import { subscribe } from 'valtio';
subscribe(state, () => {
console.log(state);
});
As you can see, the counters are updating as expected. The issue, therefore, does not lie in the inability to increment the counters, but rather in the failure to display the changes on the screen. It is possible that Svelte is not recognizing the changes to the counters, and therefore, it is not updating the elements to show the latest value of the counters.
So, what can we do to make Svelte aware of the changes to Valtio’s proxy state?
One approach would be to use a Svelte store, as it provides a mechanism for Svelte components to react to changes in data. We can turn Valtio’s proxy state into a Svelte store. Then, by subscribing to the store, Svelte will be aware of the changes to the state and will update the elements accordingly.
This approach of converting states from other state management libraries into a Svelte store in order to take advantage of Svelte’s built-in update mechanism is very common. It allows developers to use their preferred state management solution while still taking advantage of Svelte’s reactive capabilities.
So, let’s see how we can turn Valtio’s proxy state into a Svelte store. To start off, I am creating a function called `valtioStateToSvelteStore`:
function valtioStateToSvelteStore(proxyState) {
return {};
}
Before creating the Svelte store from the Valtio proxy state, let’s have a quick recap on what the Svelte store contract is. The Svelte store contract dictates that a Svelte store should have a `subscribe` method and an optional `set` method. The `subscribe` method takes a callback function as its only parameter, which will be called whenever the store’s value changes, and returns an `unsubscribe` function to stop further notifications.
So, let’s define the `subscribe` method in the returned object of the `valtioStateToSvelteStore` function, like so:
function valtioStateToSvelteStore(proxyState) {
return {
subscribe(fn) {
},
};
}
The initial value of the Svelte store can be defined by calling the callback function synchronously with the value of the proxy state:
function valtioStateToSvelteStore(proxyState) {
return {
subscribe(fn) {
fn(snapshot(proxyState));
},
};
}
Our next step is to subscribe to changes in the Valtio proxy state:
function valtioStateToSvelteStore(proxyState) {
return {
subscribe(fn) {
fn(snapshot(proxyState));
return subscribe(proxyState, () => {
fn(snapshot(proxyState));
});
},
};
}
Based on the Svelte store contract, within the `subscribe` method, we need to return a function to unsubscribe the callback function from the Svelte store. In our code, the reason we return the return value of the `subscribe` function from Valtio in our `subscribe` method is that the return value of Valtio’s `subscribe` function is a function to unsubscribe from the proxy state.
A function to unsubscribe from changes is just what we need. Isn’t this convenient?
It’s no coincidence that most state management libraries provide methods for subscribing to state changes and unsubscribing from them, just like what we need to define a Svelte store. This is because both Svelte stores and state management libraries are designed based on the Observer pattern we discussed in *Chapter 8*. In summary, to turn a state management library’s state into a Svelte store, we need to understand how the library works and how its APIs translate into the Svelte store contract.
Now that we have a function to transform a Valtio’s proxy state into a Svelte store, let’s try to use it by running the following code:
计数 #1: {$store.count1}
计数 #2: {$store.count2}
Clicking on any button in the component, you will see that the counter works perfectly fine now.
So, this is how we turn a Valtio proxy state into a Svelte store.
The next thing I would like to explore is creating a Svelte store that subscribes only to partial updates of the Valtio proxy state. By selecting only a specific portion of the state to monitor, we can ensure that the Svelte store updates only when a particular part of the state changes.
Before we do that, let’s add a few more lines to show you what I mean:
As you click on either of the increment buttons, you will notice that both reactive statements are called, indicating that `$store` is updated whenever either `count1` or `count2` is updated.
As discussed in the third tip earlier in the chapter, if state changes cause unnecessary code to run, we can derive a smaller state from the original state to only subscribe to partial updates. So, let’s do that:
Here, instead of turning `state` into a Svelte store, we are turning `state.count1` into a Svelte store. This allows us to create separate Svelte stores that only subscribe to a portion of the proxy state.
This should work, but unfortunately, it doesn’t. The reason for this has nothing to do with our code but with the data structure of our state. `state.count1` is a primitive value, which Valtio is unable to subscribe to.
To work around this, I’m going to change the data type of `state.count1` from a primitive value to an object:
export const state = proxy({
count1: { value: 0 },
count2: { value: 0 },
});
export function increment1() {
state.count1.value ++;
}
In the preceding code snippet, we changed `state.count1` to an object with a property called `value`. We still keep the code of deriving the `count1` Svelte store from the `state.count1` proxy state. So, now, the derived Svelte store of `count1` would be an object, and to get the value of the count, we will be referring to `$count1.value` instead of `$count1`:
$: console.log('count1: ', count1. 相反,当你点击另一个按钮,标记为 count2。只有当相应的计数器增加时,响应式语句才会运行。这是因为我们的 count1 和 count2 存储只在 Valtio 代理状态的一个特定部分发生变化时更新。
那么,让我们总结一下到目前为止我们所做的工作。
我们通过将 Valtio 代理状态转换为 Svelte 存储,探索了 Valtio 在 Svelte 中的集成。一步一步地,我们通过利用 Valtio 内置的订阅和取消订阅方法,实现了 Svelte 存储合约。在最后,我们探讨了如何订阅代理状态的局部更新,以最小化不必要的响应性。
我相信你现在一定迫不及待想要亲自尝试一下,那么我们就用下一个状态管理库来做练习。
练习 – 将 XState 状态机转换为 Svelte 存储
XState (xstate.js.org/) 是一个用于使用状态机管理状态的 JavaScript 库。在这个练习中,你将把一个 XState 状态机转换成一个 Svelte 存储。
XState 提供了一个名为 @xstate/svelte 的包,其中包含专门为在 Svelte 中集成 XState 设计的实用工具。尽管这个包可以为你的任务提供灵感,但在这个练习中,使用 @xstate/svelte 是不允许的。这里的目的是挑战你实现一个将 XState 状态机转换为 Svelte 存储的功能,而不使用现有的集成实用工具。
要开始这个练习,请按照以下步骤操作:
理解 XState:如果你还不熟悉 XState,花些时间阅读其文档并尝试其功能。了解状态机的工作原理以及 XState 如何实现它们。
创建状态机:使用 XState 创建一个你想要转换为 Svelte 存储的状态机。这可能是一个具有几个状态和转换的简单机,或者如果你感到舒适,也可以是更复杂的机。
将状态机转换为 Svelte 存储:现在到了练习的关键部分。你需要编写一个函数,将你的 XState 状态机转换为 Svelte 存储。这涉及到订阅机器中的状态变化并将它们转发到 Svelte 存储,以及将存储中的操作映射回状态机的转换。
测试你的实现:在你完成转换后,确保你彻底测试它。更改存储中的状态,并观察是否在状态机中反映了相同的更改,反之亦然。
如果你遇到了困难,退一步参考 XState 和本章的文档,或者 @xstate/svelte 的源代码。
摘要
在本章中,我们学习了如何使用 Svelte 管理我们的应用程序状态。
在本章的开头,我们讨论了一些通过 Svelte 存储管理 Svelte 中状态的技巧。随着你的 Svelte 应用程序变得更大、更复杂,这些技巧将对你非常有用。
我们讨论的其中一个技巧是使用状态管理库来管理数据变化和数据流。这就是为什么我们在本章的后半部分探讨了如何在 Svelte 中使用状态管理库,通过将状态管理库的状态转换为 Svelte 存储。
在下一章中,我们将探讨如何结合使用 Svelte 上下文和 Svelte 存储来创建无渲染组件——不渲染任何内容的逻辑组件。
第十一章:无渲染组件
无渲染组件是 Svelte 中的一个高级概念,它允许开发者创建不包含任何 HTML 元素的可重用组件。
这种技术在利用 Svelte 在画布或 3D 上下文中渲染时特别有用,其中 Svelte 不需要渲染 HTML 模板。相反,画布和Web 图形库(WebGL)提供了一个命令式 API 来在画布上生成图形。使用无渲染组件技术,可以设计出允许用户声明性地描述画布的组件,从而使组件能够将其转换为命令式指令。
无渲染组件的另一个用例是创建仅管理状态和行为,而将实际渲染的控制权留给父组件的组件。这在开发组件库时非常有用,你希望用户能够轻松地自定义组件的外观,同时让你控制状态和行为。
在本章中,我们将利用 Svelte 上下文来创建我们的无渲染组件。如果你对 Svelte 上下文不熟悉,请参阅第八章,其中我们解释了它的含义。
初始时,我们将探讨无渲染组件的概念,然后构建创建它们的技巧。随着我们开发无渲染组件,我们将分享一些无渲染组件的示例。
在本章结束时,你应该能够使用无渲染组件技术将命令式 API 转换为声明式组件。
本章讨论的主题如下:
什么是无渲染组件?
使用 Svelte 上下文构建无渲染组件
技术要求
你可以在 GitHub 上找到本章使用的代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter11。
什么是无渲染组件?
如其名所示,无渲染组件是一种不渲染任何自身 HTML 元素的组件类型。
你可能会想知道,一个不渲染任何内容的组件有什么用?
好吧,尽管它不支持渲染 HTML,但组件仍然可以做几件有用的事情,包括以下内容:
接受属性、处理它们的值,并在它们的值变化时触发副作用:即使属性值没有直接在模板中使用,它们仍然是响应式的。你可以在组件中使用属性编写响应式语句,并在属性值变化时运行它们。你可以在下面的示例代码片段中看到这一点:
<script>
export let title;
export let description;
$: document.title = `${title} - ${description}`;
</script>
即使title和description属性在模板中没有使用,title和description都会在响应式语句中使用。每当title或description属性发生变化时,第 4 行的响应式语句将重新运行并更新文档的标题。
设置文档标题、更新 cookie 值和修改上下文是副作用的好例子。
即使在组件中没有元素挂载和销毁,onMount和onDestroy也会运行。
<slot> <slot>元素允许组件的使用者传递子元素或组件。
例如,在下面的Parent组件中,我们渲染了一个default槽:
<!-- Parent.svelte -->
<slot />
然后,Parent组件的使用者可以在Parent组件下传递子元素或组件:
<script>
import Parent from './Parent.svelte';
</script>
<Parent>
<Child /> <!-- example of child components -->
<div /> <!-- example of child elements -->
</Parent>
Parent组件可以通过两种途径向子组件或元素传递数据——设置上下文数据和设置槽属性。
你可以在第四章中了解更多关于设置槽属性的信息。
使用getContext()来检索由其父组件设置的上下文值。根据父组件提供的上下文值类型,我们可以使用上下文值与父组件通信或通知它子组件的存在。
在本章后面的编写声明式画布组件部分,你将看到一个此类技术的示例。
在接下来的章节中,我们将通过创建仅执行前面列表中列出的操作,而不渲染任何 HTML 元素的组件来挑战自己。
在本章中,我们将探讨无状态组件的两个用例,即把可重复使用的无状态组件和声明性描述转换为命令性指令。让我们深入了解一下。
探索可重复使用的无状态组件
无状态组件的第一个用例是创建仅关注组件逻辑的组件。这些组件不是你典型的按钮或文本输入等组件。相反,考虑一下具有稍微复杂逻辑的组件,例如轮播、标签页或下拉菜单。尽管轮播组件的逻辑相对标准,但其外观可以根据其使用位置和方式而有很大差异。
那么,我们如何创建一个可重复使用的轮播组件,使其根据使用位置的不同而具有不同的外观?
一种解决方案是创建一个仅包含轮播逻辑的轮播组件,不包含任何特定的样式或 HTML 结构。然后,组件的消费者可以通过传递自己的样式和 HTML 结构来决定轮播组件的外观。这提供了更大的灵活性和定制性,使组件在不同环境中更具通用性和可重复使用性。
例如,轮播组件可以接受如items这样的属性,这将确定轮播中的项目列表。轮播组件可以渲染一个slot元素,该元素接受如currentIndex和setIndex这样的 slot 属性,这将代表当前活动项目的索引和设置索引的函数。这允许轮播组件管理轮播项目循环逻辑,同时让轮播组件的消费者决定实际的轮播样式和结构。
通过将轮播逻辑与特定的样式和结构分离,我们可以创建一个更模块化和可重用的组件,可以在各种环境中使用,而无需反复重写相同的逻辑。这就是无渲染组件的力量——它们允许我们创建专注于核心功能的组件,而不受任何特定渲染或样式要求的限制。
当然,在我们继续之前,明确何时不使用无渲染组件来创建轮播是很重要的。如果您需要一个包含设计和样式的完整功能的轮播组件,那么无渲染组件可能不适合您的需求,因为其主要目的是处理轮播的逻辑和行为,而不规定其外观。
最终,是否为轮播创建无渲染组件取决于您的具体需求和目标。在决定最佳方法之前,请考虑您的项目需求和设计偏好。
在下一节中,我将逐步向您展示如何构建一个无渲染的轮播组件。
示例 - 构建一个无渲染的轮播组件
轮播组件是一个 UI 组件,以循环的方式显示一组项目。它允许用户以幻灯片式的格式查看一系列项目。轮播组件通常在电子商务平台、新闻门户和社交媒体平台上找到。
图 11.1:轮播组件的示例
我们将创建一个无渲染的轮播组件,该组件通过名为items的属性接受一个项目列表。轮播组件将渲染一个slot元素,以允许自定义其外观。
slot元素将接受一些 slot 属性:
currentIndex:这代表轮播中当前显示项目的索引。
currentItem:这代表轮播中当前显示的项目。
setCurrentIndex:这是一个可以用来更新轮播当前索引的函数。它可以用来实现自定义导航控制。
next和prev:这些slot属性是函数,可以用来导航到轮播中的下一个或上一个项目。它们可以用来实现自定义导航控制或响应用户输入,如点击或滑动。
这些slot属性允许消费者决定如何使用它们来构建自己的轮播 UI。
为了确定轮播组件适当的slot属性,我们考虑了用户构建自己的轮播 UI 所需的基本状态和功能。在这种情况下,关键状态是currentIndex和currentItem,而与轮播 UI 交互所需的必要函数包括setCurrentIndex、next和prev,这些对于实现自定义导航控制非常有帮助。
轮播 UI 的一个例子是显示当前项目并具有导航前后按钮的轮播:
<Carousel {items} let:currentItem let:next let:prev>
<button on:click={prev}>{'<'}</button>
<img src={currentItem} />
<button on:click={next}>{'>'}</button>
</Carousel>
在前面的代码片段中,我们使用currentItem、next和prev slot 属性构建了一个简单的轮播 UI。我们决定如何使用slot属性,并控制 HTML 元素的布局和样式。
轮播 UI 的另一个例子是在其底部显示项目编号列表,使用户能够通过点击其编号快速跳转到所选项目:
<Carousel {items} let:currentItem let:setCurrentIndex>
<img src={currentItem} />
{#each items as _, index}
<button on:click={() => setCurrentIndex(index)}>
{index}
</button>
{/each}
</Carousel>
在前面的代码中,我使用了 Svelte 的{#each}块来创建一个按钮列表,每个按钮都有一个不同的索引编号。每个按钮都有一个点击事件监听器,将轮播的当前索引设置为按钮的索引编号。当你点击按钮时,轮播将跳转到按钮指定的索引位置的项目。
第二个轮播 UI 与第一个轮播 UI 不同,你可以创建并设计一个完全不同的轮播 UI。你可以看到轮播 UI 的外观完全取决于用户。无渲染轮播组件专注于轮播逻辑,并允许用户决定 UI 结构和样式。
不再拖延,让我们探索如何编写轮播组件。
编写无渲染轮播组件
在前面的章节中,我们决定了我们的Carousel组件的属性items,并决定它应该有一个默认的<slot>。
基于这些信息,让我们创建我们的Carousel组件的结构:
<script>
export let items;
</script>
<slot>
在前面的代码片段中,<slot>元素将是我们在Carousel组件中拥有的唯一元素。这个<slot>元素是必需的;否则,如果没有<slot>元素,在Carousel组件内部指示的以下代码片段中的子元素将被忽略并丢弃:
<Carousel>
<!-- content over here will be ignored if the Carousel don't have a <slot> element -->
</Carousel>
在<slot>元素中,我们定义以下slot属性 - currentIndex、currentItem、setCurrentIndex、prev和next:
<slot {currentIndex} {currentItem} {setCurrentIndex} {prev} {next} />
然而,这些slot属性在我们的Carousel组件中尚未定义,所以让我们来定义它们。
我们在组件开始时将currentIndex初始化为0:
let currentIndex = 0;
setCurrentIndex用于将currentIndex更新为传入的值:
const setCurrentIndex = (value) => { currentIndex = value; };
currentItem将是items数组中currentIndex索引位置的项目。在这里,我将使用响应式语句,这样每当items数组或currentIndex发生变化时,我们都会有新的currentItem slot 属性:
$: currentItem = items[currentIndex];
最后,prev和next函数将根据其当前值设置currentIndex:
const prev = () => setCurrentIndex((currentIndex - 1 + items.length) % items.length);
const next = () => setCurrentIndex((currentIndex + 1) % items.length);
在前面的代码片段中,我使用了% items.length,这样索引总是位于items数组长度的范围内。这样,我们确保在到达数组末尾后,轮播图可以回到items数组的开头,为用户创建一个无缝循环效果。
就这样。如果你将所有前面的代码片段添加到Carousel组件中,你将拥有一个可工作的无状态组件。完整的代码可在 GitHub 上找到:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter11/01-carousel。
创建一个无状态组件并不难;创建一个无状态Carousel组件并不需要我们花费太多时间。关键是要弄清楚我们无状态组件的属性,然后确定所需的slot属性,最后创建一个带有slot属性的默认<slot>元素。
在我们继续之前,让我们进行一个练习,自己创建一个可重用的无状态组件。
练习 1 - 创建一个无状态自动完成组件
自动完成组件是一个带有自动建议下拉菜单的输入框,当用户在输入框中输入时,会显示匹配选项的列表。它帮助用户快速、轻松地从大量选择中找到并选择一个选项。自动完成组件通常用于搜索栏、表单和其他需要用户快速、准确输入数据的区域。
在这个练习中,我们将创建一个无状态自动完成组件。
自动完成组件仅接受一个属性,即一个search函数,该函数接受一个搜索值并返回Promise,该Promise解析为一个字符串结果数组。
那么,我们的无状态Autocomplete组件会提供哪些插槽属性呢?
我们需要为我们的自动完成组件设置三个状态:
value:表示输入框中的当前值
searching:一个布尔值,表示自动完成当前是否正在搜索结果
suggestions:从search函数返回的自动完成结果数组
为了让用户与Autocomplete组件交互,我们需要两个函数:
setValue:用于更新输入框的值
selectSuggestion:用于选择建议并将其应用到输入框
因此,前面的三个状态和两个函数将成为我们的Autocomplete组件的插槽属性。
这里是Autocomplete组件的一个使用示例:
<Autocomplete {search} let:value let:setValue let:searching let:suggestions let:selectSuggestion>
<input {value} on:input={event => setValue(event.currentTarget.value)}>
{#if searching}Searching...{/if}
{#if suggestions}
<ul>
{#each suggestions as suggestion}
<li on:click={() => selectSuggestion(suggestion)}>{suggestion}</li>
{/each}
</ul>
{/if}
</Autocomplete>
在前面的代码片段中,我们使用Autocomplete组件来渲染一个自动完成文本框,其中包含一个<input>元素和一个<ul>元素在默认插槽中。
<input>元素使用value和setValue插槽属性来访问和修改由Autocomplete组件持有的value。
<ul> 元素使用 suggestions 插槽属性来展示 Autocomplete 组件提供的建议列表,而 selectSuggestion 插槽属性用于 <li> 元素的 click 事件处理器中,以选择选定的建议并将其应用到文本框中。
一个示例解决方案可以在github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter11/02-autocomplete找到。
现在,让我们看看无渲染组件的第二个用例。
将声明性描述转换为命令式指令
无渲染组件的第二个用例涉及允许用户以声明式方式描述他们的需求,然后将它们转换为命令式指令。
在处理画布或 WebGL 时,这是一个很好的用例示例。
例如,在画布中,要创建一个带有绿色边框的红色矩形,你需要使用命令式 API 来创建和样式化矩形:
ctx.fillStyle = 'red';
ctx.strokeStyle = 'green';
ctx.rect(10, 10, 100, 100);
ctx.stroke();
ctx.fill();
逐步指导画布上下文设置 fillStyle 和 strokeStyle,然后根据设置的填充颜色和描边颜色绘制矩形。
当以命令式方式与画布交互时,代码关注的是如何做事情,而不是要做什么。这可能导致难以阅读和维护的代码,其中包含大量低级细节,可能会使其难以看到整体情况。
相反,如果你以声明式编写代码,你描述的是你想要发生的事情,而不是它应该如何发生。这使得代码更具表现力,更容易阅读,同时也更灵活和可重用。
继续以红色矩形的例子,我们可以创建一个 Svelte 组件来处理在画布上绘制矩形。我们不需要手动编写绘制矩形的指令,而可以通过组件简单地描述我们希望在画布上看到的样子。然后,组件会为我们处理在画布上渲染矩形。
这里是一个通过 Svelte 组件描述相同矩形的代码示例:
<script>
let x = 10, y = 10, height = 100, width = 100;
</script>
<Canvas>
<Rectangle
fill="red" stroke="green"
{x} {y} {height} {width}
/>
</Canvas>
在前面的代码片段中,我们看到一个嵌套在 Canvas 组件中的 Rectangle 组件。Rectangle 组件指定了其填充、描边、x 位置、y 位置、宽度和高度。
下图说明了前面代码中的 Rectangle 组件如何渲染一个带有绿色边框的红色正方形。
图 11.2:一个带有绿色边框的红色正方形
尽管代码中看不到绘制在画布上的显式指令,但我们可以根据 Rectangle 组件可视化一个矩形在画布上被绘制。这个矩形,宽度和高度各为 100 像素,距离左边和上边各 10 像素,填充红色,边框为绿色。
通过创建一个处理低级画布指令的无渲染组件,我们可以将绘制矩形的逻辑与它应该如何渲染的具体细节分开。这允许我们在画布上以更多样化的方式显示矩形,同时也使得代码的维护更加容易。
此外,通过允许用户以声明式的方式描述他们的需求,我们创建了一个更直观、用户友好的界面来与画布和其他低级技术一起工作。这可能导致开发时间更快,开发体验更加愉快。
让我给你举一个例子,说明以声明式描述画布比强制性地指令它要快得多。如果我想动画化矩形的尺寸,而不是手动使用命令式 API 编码动画,我只需简单地动画化 height 和 width 变量的值,就像以下代码片段所示:
<script>
let x = 10, y = 10, height = 100, width = 100;
setInterval(() => {
height += 10;
width += 10;
}, 100);
</script>
<Canvas>
<Rectangle
fill="red" stroke="green"
{x} {y} {height} {width}
/>
</Canvas>
虽然使用 setInterval 可能不是创建动画的最佳方式,但前面的代码片段试图展示改变画布上矩形的高度和宽度是多么容易。
在前面的代码片段中,我们每隔一段时间更新 height 和 width。我们将 height 和 width 传递给 Rectangle 组件。正如你所见,画布上矩形的宽度和高度每 100 毫秒增加 10 px。
相反,如果我们强制性地进行动画处理,我们必须在每个间隔清除画布并重新绘制一个具有新高度和宽度的矩形。这些实现细节现在在声明式组件中被抽象出来,使得推理动画和未来修改它变得更加容易。
总体来说,使用无渲染组件来处理低级命令式任务可以极大地提高我们代码的可读性、可维护性和灵活性,同时使其更加易于访问和用户友好。
那么,我们如何实现前面代码片段中显示的 Canvas 和 Rectangle 组件呢?让我们来看看。
编写声明式 Canvas 组件
我们将从 Canvas 组件开始。
与前一小节中 Carousel 组件的组件结构类似,Canvas 组件将渲染一个 <slot> 元素来插入所有子组件。
然而,不同之处在于它还会渲染一个 <canvas> 元素,这是我们将会与之交互并在其上绘制的元素:
<script>
import { onMount } from 'svelte';
let canvas, ctx;
onMount(() => {
ctx = canvas.getContext('2d');
});
</script>
<canvas bind:this={canvas} />
<slot />
在前面的代码片段中,我们将 <canvas> 元素的引用绑定到名为 canvas 的变量。在 <canvas> 元素挂载到 DOM 之后,我们获取 <canvas> 元素的绘图上下文并将其分配给名为 ctx 的变量。
如果你还记得之前的代码示例,我们将 <Rectangle> 组件放置在 <Canvas> 组件内部,以便在画布上绘制矩形。<Rectangle> 组件不会从 <Canvas> 组件接收任何数据或插槽属性。那么,<Rectangle> 组件是如何通知 <Canvas> 组件它是 <Canvas> 组件的子组件的呢?<Canvas> 组件是如何与 <Rectangle> 组件通信,以知道要绘制什么以及如何在它的 <canvas> 元素上绘制呢?
如果你还记得 第八章,我们介绍了 Svelte 上下文作为一种设置数据并将其提供给子组件的机制。Svelte 上下文允许父组件在无需显式作为属性传递的情况下,与子组件共享数据和函数。
我们可以使用 Svelte 上下文在 <Canvas> 和 <Rectangle> 组件之间进行通信。
<Canvas> 组件可以使用绘图上下文 ctx 设置上下文:
<script>
import { setContext } from 'svelte';
// setting the context with the drawing context
setContext('canvas', () => ctx);
</script>
当 <Rectangle> 组件作为 <Canvas> 组件的子组件渲染时,它可以访问 <Canvas> 组件设置的上下文并检索 ctx 变量。在 onMount 中,<Rectangle> 获取 <canvas> 元素的绘图上下文并在画布上绘制矩形:
<script>
import { getContext, onMount } from 'svelte';
const getCtx = getContext('canvas');
onMount(() => {
const ctx = getCtxt();
// draws a rectangle onto the canvas
ctx.fillRect(...);
});
</script>
我们传递一个返回 ctx 的函数而不是直接在上下文中传递 ctx 的原因是因为 ctx 值仅在 <canvas> 元素挂载到 DOM 之后在 onMount 中可用,而 setContext 必须在组件初始化期间调用,即在 onMount 之前。在 <Rectangle> 组件中,你应该只在 onMount 中调用 getCtx() 来检索 ctx 的最新值。
通过利用 Svelte 上下文,<Canvas> 和 <Rectangle> 组件可以保持清晰且高效的通信通道。<Canvas> 组件创建画布并提供绘图上下文,而 <Rectangle> 组件访问画布并执行绘图任务。
要完成 <Rectangle> 组件,我们需要在 x 或 y 位置或宽度和高度尺寸发生变化时重新绘制矩形。为此,我们将使用响应式语句:
<script>
// ...
export let x, y, width, height;
$: draw(x, y, width, height);
function draw(x, y, width, height) {
const ctx = getCtx();
ctx.fillRect(x, y, width, height);
}
</script>
在前面的响应式语句中,每当 x、y、width 或 height 值发生变化时,draw 函数会重新执行。通过使用这种方法,<Rectangle> 组件可以高效地根据位置或尺寸的变化更新其外观,确保渲染的矩形始终反映最新的状态。
然而,你可能注意到它没有按预期工作。你会看到新矩形被绘制在旧矩形之上。这是因为我们需要在绘制另一个矩形之前清除画布。我们不能用我们的 draw 函数来做这件事,因为如果有多个 <Rectangle> 组件,这会导致不希望的结果。每个组件都会在绘制自己的矩形之前清除画布,结果只有最后一个 <Rectangle> 组件是可见的。
为了解决这个问题,我们需要 <Canvas> 组件在重新绘制所有子矩形之前清除画布。这个函数可以在任何子矩形组件请求更新时调用。
让我们在 <Canvas> 组件中定义一个 redrawCanvas 函数来重新绘制画布。在 redrawCanvas 函数中,我们首先通过 ctx.clearRect() 清除画布。以下是 <Canvas> 组件的更新代码:
<script>
// ...
function redrawCanvas() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// TODO: here we need to redraw all the rectangles
}
// Provide both the drawing context and the clearCanvas function to child components
setContext('canvas', { getCtx: () => ctx, redrawCanvas });
</script>
在 redrawCanvas 函数中,我们希望在清除画布后重新绘制所有矩形。但我们如何做到这一点?一个想法是,而不是为所有子组件提供绘图上下文 ctx 并让组件决定何时以及如何绘制到画布上,我们可以提供一个函数让组件注册它们的 draw 函数。这样,<Canvas> 组件可以在需要重新绘制画布时调用这些 draw 函数。
在 <Canvas> 组件中,我们将 getCtx 函数改为 registerDrawFunction:
<script>
const drawFunctions = new Set();
function registerDrawFunction(drawFn) {
drawFunctions.add(drawFn);
return () => {
drawFunctions.delete(drawFn);
};
}
function redrawCanvas() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// Redraw all the rectangles
for (const drawFn of drawFunctions) {
drawFn(ctx);
}
}
setContext('canvas', { registerDrawFunction, redrawCanvas });
</script>
在前面的 redrawCanvas 函数中,我们遍历从子组件注册的 drawFunctions 并使用绘图上下文 ctx 调用它们。这样,我们不需要通过 Svelte 上下文提供 ctx,但子组件可以在它们的 draw 函数中获取最新的 ctx。
最后,让我们在 <Rectangle> 组件中注册我们的 draw 函数:
<script>
// ...
const { registerDrawFunction, redrawCanvas } = getContext('canvas');
function draw(ctx) {
ctx.fillRect(x, y, width, height);
}
onMount(() => {
// register the draw function
const unregister = registerDrawFunction(draw);
return () => {
unregister();
redrawCanvas();
};
});
// call redrawCanvas when x, y, height, width changes
$: x, y, height, width, redrawCanvas();
</script>
在 onMount 回调中,我们通过 Svelte 上下文注册 <Rectangle> 组件的 draw 函数。当组件被销毁并从 DOM 中移除时,<Rectangle> 组件会取消注册自己并调用 redrawCanvas 函数。这确保了 <Canvas> 组件被更新,并且画布上的移除矩形被清除。
此外,通过在 x、y、height 或 width 发生变化时调用 redrawCanvas 函数,<Rectangle> 组件确保其位置和尺寸在画布上得到准确反映。这样,<Canvas> 组件始终保持其子组件的最新视觉表示。
现在,<Canvas> 组件完全控制整个画布的重新绘制,<Rectangle> 组件可以将其 draw 函数注册到 <Canvas> 组件上。这种方法确保在重新绘制之前总是清除画布,并允许多个 <Rectangle> 组件共存而不会相互干扰。
我们现在有了功能性的 <Canvas> 和 <Rectangle> 组件。在整个创建这些组件的过程中,我们将命令式画布操作转换为更易于管理的声明式组件。
为了促进父组件和子组件之间的通信,我们使用了 Svelte 上下文作为通信渠道。正如前述代码片段所示,父 <Canvas> 组件维护一个从其子组件继承的 draw 函数列表,使其能够按需调用它们。这种通用模式可以应用于需要跟踪和调用其子组件方法的父组件。
虽然我们编写的代码是可行的,但它可能还需要一些改进。要访问完整的可工作代码,包括任何额外的功能或优化,以便实现一个完善和全面的实现,请访问 GitHub 仓库:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter11/03-canvas。
练习 2 – 扩展形状组件
在这个练习中,我们挑战您创建额外的形状组件,以扩展您现有的 <Canvas> 组件的功能。
您可以创建的形状示例包括以下内容:
一个 <Circle> 组件,它接受 x、y、radius 和 color 作为属性。该组件应在指定的坐标和指定的半径和颜色上绘制画布上的圆。
一个 <Line> 组件,它接受 x1、y1、x2、y2 和 color 作为属性。该组件应在指定的颜色下在两个坐标集之间绘制画布上的线。
其他形状组件,如 <Ellipse> 和 <Triangle>。
您可能需要参考 Canvas API 文档来学习如何绘制不同的形状。
您可以在 github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter11/03-canvas 找到 <Circle> 和 <Line> 组件的代码。
摘要
在本章中,我们深入探讨了 Svelte 中无渲染组件的概念,并探讨了它们的各种用例。理解无渲染组件为您提供了创建可重用组件的新工具集。无渲染组件通过关注核心逻辑、状态和行为来强调可重用性,同时将视觉呈现留作灵活定制。
通过使用插槽属性,我们演示了如何构建一个可重用且允许用户控制其外观的无渲染组件,同时保持组件逻辑并将命令式操作转换为声明式 Svelte 组件。
我们还展示了将命令式操作转换为声明式 Svelte 组件的实际示例。我们演示了如何创建 <Canvas> 和 <Rectangle> 组件,这些组件可以在画布上绘制矩形,其大小可以动态变化。
在下一章中,我们将探讨如何将 Svelte 存储和动画结合,以创建流畅、动态的应用程序。
无状态组件
第十二章:存储和动画
在本章中,我们将深入探讨 Svelte 动画的世界,重点关注 tweened 和 spring 存储的强大功能和多功能性。tweened 和 spring 存储是可写存储,当调用 set 或 update 方法时,它们的存储值会随时间变化,使我们能够开发更复杂且视觉上吸引人的动画。通过有效地利用这些存储,您可以提升用户体验,并创建既动态又吸引人的应用程序。
我们从深入了解 tweened 和 spring 存储开始本章,学习如何使用这些存储创建动画。随后,我们探索插值和自定义插值的使用。在整个章节中,我们检查了各种示例,例如动画图表和图像灯箱,以说明概念。到本章结束时,您将掌握有效利用 tweened 和 spring 存储所需的技能,使您能够在 Svelte 项目中创建复杂且引人入胜的动画。
本章涵盖了以下主题:
tweened 和 spring 存储简介
自定义插值及其使用
使用 tweened 和 spring 存储进行动画
技术要求
您可以在 GitHub 上找到本章使用的代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter12
介绍 tweened 和 spring 存储
让我们通过理解 tweened 和 spring 存储的概念,开始我们的 Svelte 动画之旅。
tweened 和 spring 存储是通常包含数字值的可写存储。为了了解它们提供的功能,让我们将它们与一个常规的数字变量进行比较。
如果您不熟悉可写存储,您可以查看第八章,在那里我们详细解释了 Svelte 存储,以及如何使用内置的 writable() 函数创建可写 Svelte 存储。
通常,当您有一个数字变量并且更新该变量时,变量的值会立即改变。在以下示例中,我们有一个初始值为 10 的数字变量 height。当我们将该变量的新值设置为 20 时,变量的值会立即变为 20:
let height = 10;
height = 20;
如果我们使用这个数字变量来表示元素的高度或进度条中的进度,一旦赋值,高度或进度就会跳转到新值。这些突然的变化可能会让人感到震惊。
那么,我们如何确保在更新目标值时有一个平滑的过渡?
Svelte 提供了两个内置存储,tweened 和 spring,专门设计用于存储数字值,并允许在指定的时间内平滑过渡到新值。
让我们通过一个示例来获得更清晰的概念。
在示例中,我们创建了一个初始值为 10 的 tweened 存储:
import { tweened } from 'svelte/motion';
const height = tweened(10, {
duration: 1000 /* 1 second */
});
然后,我们将存储库的新值设置为20:
$height = 20;
当完成这些操作后,存储库的值在一秒钟内逐渐从10增加到20。如果我们将这个存储库值用作元素的宽度,那么当我们将新值分配给存储库时,元素的宽度将平滑地增长或缩小到目标值,从而实现视觉上吸引人且流畅的过渡。
让我们尝试将高度从tweened存储库更改为spring存储库。请不要担心spring函数中的选项,因为我们将它们在下一节中解释:
import { spring } from 'svelte/motion';
const height = spring(10, {
stiffness: 0.1,
damping: 0.25
});
现在,当你为存储库分配新值时,你会注意到,与使用tweened存储库时类似,存储库的值随时间变化到新值,但变化速率不同。
如您所见,tweened和spring存储库是 Svelte 中的强大功能,使您能够在应用程序中创建平滑的动画和过渡。这些存储库允许进行平滑的值变化。当用作组件状态时,它们允许以更自然和流畅的方式更新状态。
tweened存储库和spring存储库之间的区别在于,tweened存储库提供了一种使用缓动函数在指定的时间内平滑过渡两个值的方法,而spring存储库是为基于物理的动画设计的,其中元素表现得就像它们被连接到一个弹簧上。
让我们深入探讨如何使用tweened和spring存储库。
使用tweened和spring存储库
tweened是一个存储库,它使用选择的缓动函数在指定的时间内平滑地过渡到数值。
这是创建一个tweened存储库的方法:
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
const progress = tweened(0, {
duration: 1000,
easing: cubicOut,
delay: 500,
});
在前面的代码片段中,我们创建了一个名为progress的tweened存储库,其初始存储库值为0。当你为progress存储库设置新值时,progress存储库的存储库值在 0.5 秒内保持不变,然后使用cubicOut缓动函数在 1 秒内过渡到新值。
tweened存储库的函数签名如下:
import { tweened } from 'svelte/motion';
const value = tweened(initialValue, options);
initialValue是存储库的初始数值。
options是一个包含以下属性的对象:
duration(默认:400):过渡的持续时间(以毫秒为单位)。
easing(默认:linear):用于过渡的缓动函数。
Svelte 在svelte/easing模块中提供了各种缓动函数,例如linear、quadIn和expoOut。您还可以创建自定义缓动函数。
delay(默认:0):在过渡开始之前的延迟(以毫秒为单位)。
另一方面,spring是一个存储库,它使用基于弹簧的物理模拟平滑地过渡到数值。
以下是创建一个spring存储库的方法:
import { spring } from 'svelte/motion';
const position = spring(0, {
stiffness: 0.2,
damping: 0.5,
precision: 0.001,
});
在前面的代码片段中,我们创建了一个名为position的spring存储,其初始存储值为0。当您为position存储设置新值时,此值将弹向目标值,并在目标值周围振荡一段时间,直到稳定下来。振荡的幅度和持续时间取决于配置的stiffness、damping和precision值。
spring存储的函数签名如下:
import { spring } from 'svelte/motion';
const value = spring(initialValue, options);
initialValue是存储的初始数值。
options是一个包含以下属性的对象:
stiffness(默认:0.15):弹簧的刚度。较高的值会导致更刚性的弹簧,从而引起更快、更有力的转换。
damping(默认:0.8):弹簧的阻尼系数。较高的值会导致更多的阻尼,使弹簧更快地稳定下来。
precision(默认:0.01):当弹簧被认为处于静止状态时的阈值。较小的值会导致更精确的模拟,但可能需要更长的时间才能稳定。
使用数组和对象与tweened和spring存储
tweened和spring存储不仅可以处理单个数值,还可以处理数字数组和具有数值属性的对象。这使得创建涉及多个值的复杂动画变得容易。
当您传递一个数字数组作为初始值时,存储将独立平滑地过渡数组的每个元素。以下是一个使用两个数字数组的示例:
import { tweened } from 'svelte/motion';
const coordinates = tweened([0, 0], { duration: 1000 });
// Updating the coordinates
$coordinates = [100, 200];
类似地,当您传递具有数值属性的对象作为初始值时,存储将独立平滑地过渡每个属性。以下是一个使用具有两个数值属性的对象的示例:
import { tweened } from 'svelte/motion';
const position = tweened({ x: 0, y: 0 }, { duration: 1000 });
// Updating the position
$position = { x: 100, y: 200 };
当使用数组或对象时,您可以在 Svelte 组件中如下访问和使用单个值:
<script>
import { tweened } from 'svelte/motion';
const position = tweened({ x: 0, y: 0 }, { duration: 1000 });
</script>
<div style="transform: translate({$position.x}px, {$position.y}px)"></div>
处理数组和对象的能力使得tweened和spring存储更加灵活和强大,让您能够轻松创建复杂的动画。
现在我们已经了解了如何使用tweened和spring存储,让我们用它们来创建一个动画图表。
示例 - 使用tweened和spring存储创建动画图表
在本节中,我们将探讨一个示例,展示tweened和spring存储的强大功能。我们将创建一个动画柱状图,其中柱子会动态调整大小以反映更新的数据值。通过向柱状图添加动画,我们可以有效地突出数据变化,并深入了解复杂的数据集。
首先,让我们创建一个柱状图组件:
<script>
let data = generateData(10);
function generateData(length) {
const result = new Array(length);
for (let i = 0; i < length; i ++) {
result[i] = Math.random() * 300;
}
return result;
}
</script>
<style>
.bar {
background-color: steelblue;
height: 50px;
}
</style>
<div>
{#each data as value}
<div class="bar" style="width: {value}px"></div>
{/each}
</div>
在提供的代码片段中,我们使用generateData函数初始化data变量,该函数使用length参数创建一个指定长度的随机生成数据数组。
使用 data 数组,我们通过 {#each} 块为数组中的每个项目创建一个 <div> 元素,并将 <div> 元素的宽度设置为数组中相应项目的值。
因此,我们得到了一个显示 10 个条形的水平条形图,它们的宽度是随机生成的。
为了使事情更有趣,我们将以固定间隔更新条形图的值:
import { onMount } from 'svelte';
onMount(() => {
const intervalId = setInterval(() => {
data = generateData(10);
}, 1000);
return () => clearInterval(intervalId);
});
组件挂载后,我们立即使用 setInterval 启动一个 1 秒的间隔。在每次间隔中,我们通过使用 generateData(10) 重新生成数据来更新数据。
通过添加这段新代码,你会观察到水平条在每次间隔期间改变它们的宽度。水平条的宽度突然调整,产生一种令人不适的视觉效果。
现在,让我们利用 tweened 存储在数据更改时使条形图平滑地增长或缩小:
首先,让我们从 svelte/motion 中导入 tweened 存储。然后,我们将使用 tweened 存储包装 data 数组。如前一小节所示,我们可以将数字数组传递给 tweened 函数以创建一个 tweened 存储。当更新时,此存储将独立平滑地过渡数组中的每个数字:
import { tweened } from 'svelte/motion';
const data = tweened(generateData(10));
现在,由于 data 是一个 Svelte 存储,在更改时我们需要使用 $data 变量而不是直接更新其值:
data in the {#each} block, we need to use the $data variable as well:
{#each $data as value}
将所有这些更改放在一起,你现在将观察到水平条形图平滑地增长和缩小,大大提高了条形图的视觉吸引力和用户体验。
如你所见,使用 tweened 存储创建平滑动画的图表相当简单。在我们进入下一节之前,我们鼓励你亲自尝试:将 tweened 函数替换为 spring 并观察动画的变化。
练习 – 创建一个动态的折线图
现在你已经看到了如何创建动态条形图,现在是时候尝试创建一个动态折线图了。
你可以使用 d3-shape 库 (github.com/d3/d3-shape),它提供了一个方便的 line 方法,该方法基于项目数组生成 SVG 路径。以下是如何使用 line 方法的示例:
const pathGenerator = line().x((d, i) => i).y((d) => d);
const path = pathGenerator(data);
在前面的代码片段中,我们使用 line 方法创建了一个 pathGenerator 函数,该函数通过将数组的值映射到 y 坐标来生成 SVG 路径。你可以通过在 SVG 中使用返回的 SVG 路径和 <path> 元素来创建折线图。
一旦你完成了你的实现,请随意将你的结果与我们的示例进行比较,示例在 github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter12/02-line-chart。祝你好运,享受实验你的动态折线图吧!
在熟悉了使用 tweened 和 spring 存储器处理数字、数组和对象之后,现在是时候将非数值值纳入其中了。这将通过创建自定义缓动插值器来实现,我们将在下一节中探讨。
创建自定义缓动插值器
有时,你可能想要在非数值值之间进行转换,例如颜色。幸运的是,这并不妨碍我们使用 tweened 存储器。tweened 函数提供了一个选项来定义两个值之间的自定义插值。
在本节中,我们将探讨如何创建自定义插值,以在两种颜色之间实现平滑过渡。
但什么是插值?
当在两个值之间进行转换时,插值意味着在两个值之间生成中间值,以创建平滑的过渡。
例如,考虑一个初始化为 0 的 tweened 存储器,并将其设置为 100。tweened 存储器生成 0 和 100 之间的中间值,例如 20、40、60 等等,同时使用这些中间值更新存储器值。因此,在从 0 到 100 的转换过程中,存储器值平滑地变化,提供了一个从 0 到 100 的视觉上吸引人的进度。
生成中间值的过程被称为插值。
默认的 tweened 存储器能够插值两个数字、两个数字数组以及具有数值属性值的两个对象。然而,它不知道如何插值两个颜色。在这种情况下,我们可以在创建 tweened 存储器时传递一个自定义插值函数。
tweened 存储器插值的函数签名如下:
function interpolate(a, b) {
return function (t) {
// calculate the intermediate value between 'a' and 'b' based on 't'
};
}
在这个函数中,a 和 b 代表起始值和结束值,而 t 是一个介于 0 和 1 之间的值,表示转换的进度。interpolate 函数应该返回另一个函数,该函数根据 t(转换的进度)计算并返回中间值。
例如,一个线性插值两个数字的 interpolate 函数看起来像这样:
function interpolate(a, b) {
return function (t) {
return a + t * (b – a);
};
}
interpolate 函数返回另一个函数,该函数接受一个进度值 t,并根据 t 值在 a 和 b 之间计算线性插值。当 t 为 0 时,函数返回 a,当 t 为 1 时,它返回 b。对于 t 在 0 和 1 之间的值,结果是 a 和 b 之间的比例值。要为颜色创建插值函数,我们可以将颜色分解成单个 红色、绿色和蓝色(RGB)分量,并分别对每个分量进行插值。在插值每个分量之后,我们可以将它们重新组合以形成中间颜色。
或者,我们可以使用已经实现了此类插值的库。此类库的一个好例子是 d3-interpolate (github.com/d3/d3-interpolate)。通过使用经过良好测试的库如 d3-interpolate,我们可以节省时间并确保我们的颜色插值既准确又高效。
下面是一个使用 d3-interpolate 的示例:
import { interpolateRgb } from 'd3-interpolate';
function interpolate(a, b) {
const interpolateColor = interpolateRgb(a, b);
return function (t) {
return interpolateColor(t);
};
}
在前面的代码片段中,我们从 d3-interpolate 导入了 interpolateRgb 函数,该函数返回用于颜色 a 和 b 的插值函数。然后我们创建了一个自定义的插值函数 interpolateColor,它返回一个基于进度 t 计算中间颜色的函数。
当创建 tweened 存储库时,我们可以通过将 interpolate 函数作为第二个参数传递来使用我们的自定义 interpolate 函数:
tweened(color, { interpolate: interpolate });
就这样;现在您可以为颜色创建一个可以平滑过渡的 tweened 存储库。
您可以在 GitHub 上找到使用颜色插值的代码示例:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter12/04-interpolation。
到现在为止,您已经学会了如何使用 tweened 存储库创建动画图表,以及如何使用自定义 interpolate 函数在非数值之间进行转换。
让我们探索更多使用 tweened 和 spring 存储库创建流畅用户界面的示例。
示例 - 创建动画图像预览
在这个例子中,我们将创建一个图像预览功能,允许用户在点击缩略图时查看更大、更详细的版本,从而增强用户的视觉体验,并使他们能够更仔细地检查图像。
在构建此功能时,您将看到我们如何利用 spring 存储库创建更流畅、更自然的用户体验,使图像及其更大预览之间的转换感觉平滑且引人入胜。
首先,让我们创建一个将在页面上显示的图像列表:
<script>
const images = [
"path/to/image1.jpg",
"path/to/image2.jpg",
// ...more image paths
];
const imgElements = [];
</script>
<div class="image-container">
{#each images as image, index}
<div>
<img src={image} bind:this={imgElements[index]} />
</div>
{/each}
</div>
在这个例子中,我们创建了一个包含图像文件路径的图像数组。我们使用 {#each} 块遍历图像,并为每个图像创建一个包含 <img> 元素的 <div> 元素。
在前面的代码片段中,省略了 <style> 部分,因为它对于理解代码功能不是必需的。如果您想了解样式的外观,可以在 github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter12/03-image-preview 找到它们。
我们将 <img> 元素的引用保存在 imgElements 变量中。这将在以后很有用。
为了预览图像并关闭预览,我们需要实现两个函数 openPreview 和 closePreview,以及一个变量 selectedImageIndex 来跟踪当前预览的图像:
<script>
let selectedImageIndex = -1;
function openPreview(index) {
selectedImageIndex = index;
}
function closePreview() {
selectedImageIndex = -1;
}
</script>
...
<img
src={image}
bind:this={imgElements[index]}
on:click={() => openPreview(index)}
/>
在前面的代码片段中,我们将 selectedImageIndex 初始化为 -1,表示没有选择任何图片。openPreview 函数设置 selectedImageIndex,而 closePreview 函数取消设置。最后,我们添加一个点击事件监听器,以便在点击的图片上调用 openPreview 函数。
为了为我们的图片预览创建一个黑色背景,我们添加了一个 <div> 元素,该元素仅在图片被选中时具有 .backdrop 类。点击背景将关闭预览:
<div
class:backdrop={selectedImageIndex !== -1}
on:click={closePreview}
/>
为了显示图片预览,我们的目标是强调、放大并在屏幕上居中显示图片。为了实现这一点,我们必须确定放大图片的目标宽度和高度,并计算所需的 x 和 y 位置以使其居中。
为了简单起见,让我们假设图片的宽高比是 1:1。我们将目标宽度和高度设置为窗口高度和窗口宽度之间较小值的 80%。确定了目标高度和宽度后,我们可以使用这些值来计算在屏幕上居中图片所需的 x 和 y 位置。让我们看看如何:
<script>
let selectedImageIndex = -1;
let width, height, left, top;
function openPreview(index) {
selectedImageIndex = index;
width = Math.min(window.innerWidth, window.innerHeight) * 0.8;
height = width; // same as width, assuming 1:1 ratio
left = (window.innerWidth - width) / 2;
top = (window.innerHeight - height) / 2;
}
</script>
...
<img
src={image}
bind:this={imgElements[index]}
on:click={() => openPreview(index)}
style={selectedImageIndex === index ? `
position: fixed;
left: ${left}px;
top: ${top}px;
width: ${width}px;
height: ${height}px;
` : ''}
/>
在代码片段中,我们设置了选中图片的样式。当图片被选中时,我们使用 position: fixed 来定位它,允许我们设置图片相对于视口的 left 和 top 位置。到此为止,我们就有一个简单的图片预览组件了。
现在,让我们转向一个有趣的问题:我们如何使用 spring 存储使预览更加流畅,而不是突然将图片放置在屏幕中心?
一个想法是使用 transform: translate 而不是直接设置左和顶位置来居中图片。我们可以保持左和顶位置不变,并使用 transform: translate 将图片移动到屏幕中心。平移偏移量的值将来自 spring 存储。
使用 transform: translate 而不是更新左和顶位置的原因是,它允许更平滑、更高效的动画,因为它不像更新位置属性(如 left 和 top)那样频繁地触发布局重新计算和重绘。此外,使用 transform: translate 使得只需将平移偏移量重置为 0,就可以轻松地将图片重置回原始位置。
同样,我们可以将这个想法应用到图片的宽度和高度上。我们可以保持原始图片的尺寸,并使用 transform: scale 来调整图片大小。
在有了这个想法之后,让我们来看代码。在这里,我将转换初始化为一个 spring 存储:
const transform = spring(
{ translate: { x: 0, y: 0 }, scale: { x: 1, y: 1 } },
{ stiffness: 0.1, damping: 0.25 }
);
transform 的 spring 存储的默认值是一个 0 平移偏移量和 1 的缩放比例。
在将图片设置为使用 position: fixed 后,为了保持图片在原始位置,我们需要获取 <img> 元素的当前位置和尺寸,这可以通过 getBoundingClientRect 来实现:
function openPreview(index) {
// ...
const rect = imgElements[index].getBoundingClientRect();
left = rect.left;
top = rect.top;
width = rect.width;
height = rect.height;
}
我们之前计算用于left、top、width和height值的公式,以实现图像居中和放大,将成为我们的目标left、top、width和height。它们将被用来计算位移偏移量和缩放:
const targetWidth = Math.min(window.innerWidth, window.innerHeight) * 0.8;
const targetHeight = targetWidth;
const targetLeft = (window.innerWidth - targetWidth) / 2;
const targetTop = (window.innerHeight - targetHeight) / 2;
$transform = {
translate: {
x: targetLeft - left,
y: targetTop - top
},
scale: {
x: targetWidth / width,
y: targetHeight / height
},
};
位移偏移量是通过目标位置与实际位置之间的差值来计算的,而缩放则是目标宽度和高度与实际宽度和高度之间的比率。
将变换值合并到<img>样式中的方法如下:
<img
style={selectedImageIndex === index ? `
...
transform: translate(${$transform.translate.x}px, ${$transform.translate.y}px) scale(${$transform.scale.x}, ${$transform.scale.y});
` : ''}
通过这些更改,当点击图像时,图像将平滑地弹回到中心,从而创造一个更加流畅和自然的用户体验。
现在,轮到你自己尝试了。通过使用spring存储库创建一个不透明度值,并使用这个值来调整图像预览背景的暗度。这将进一步增强图像预览组件的流畅性和视觉吸引力。
摘要
在本章中,我们探讨了 Svelte 的tweened和spring存储库。
我们探讨了如何使用tweened和spring存储库来创建平滑的动画和过渡,增强了视觉吸引力和用户体验。通过使用自定义插值函数并将它们应用于非数值,例如颜色,我们扩展了创建动态和引人入胜的用户界面元素的可能性。
在本章中,我们看到了多个tweened和spring存储库在实际应用中的例子,了解了如何轻松地使用tweened和spring存储库来创建动画。希望你现在在使用 Svelte 项目中使用tweened和spring存储库时更加得心应手。
这是本章最后讨论 Svelte 上下文和 Svelte 存储库的部分。在下一章中,我们将探讨过渡,特别是如何在 Svelte 组件中使用过渡。
第四部分:过渡
在本节的最后部分,我们将深入探讨 Svelte 的过渡。我们将首先了解如何将内置过渡集成到我们的 Svelte 组件中。随后,我们将指导你创建自己的自定义过渡。为了结束本节,我们将强调可访问性以及如何通过过渡创建一个满足所有用户需求的应用程序。
本部分包含以下章节:
第十三章**,使用过渡
第十四章**,探索自定义过渡
第十五章**,过渡中的可访问性
第十三章:使用过渡效果
过渡效果对于创建平滑且引人入胜的用户体验至关重要。通过定义元素在用户界面中的出现、消失或变化方式,过渡效果可以将普通的交互转变为令人难忘的体验,给用户留下深刻印象。
在接下来的三章中,我们将探讨 Svelte 中的过渡效果主题,首先从全面了解如何在 Svelte 中使用过渡效果开始。
在本章中,我们将首先学习如何在 Svelte 中为元素添加过渡效果。我们将探讨不同的过渡指令,并学习如何自定义过渡效果。
之后,我们将讨论何时以及如何播放过渡效果。我们将探讨不同的场景,例如元素中包含和不含过渡效果的混合,或者当元素位于嵌套的逻辑块中时。
要真正掌握过渡效果,了解 Svelte 过渡系统的内部工作原理至关重要。我们将通过检查底层机制并提供有助于你在项目中优化过渡效果使用的见解来结束本章。
到本章结束时,你将拥有 Svelte 过渡效果的坚实基础,让你能够轻松创建引人入胜且动态的用户界面。
本章包括以下内容:
如何为元素添加过渡效果
元素的过渡效果何时播放
过渡效果在底层是如何工作的
技术要求
你可以在 GitHub 上找到本章使用的代码:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter13。
为元素添加过渡效果
Svelte 提供了一种简单而强大的方法来为你的应用元素添加过渡效果。该框架提供了内置的过渡函数,可以轻松应用于元素,从而实现平滑的动画和无缝的用户体验。你还可以定义自己的自定义过渡效果,我们将在下一章中学习。
Svelte 中的过渡效果是在元素挂载或从 DOM 卸载时应用的。这确保了元素的出现和消失都是优雅的,而不是突然出现在视野中或消失。
要在 Svelte 中为元素添加过渡效果,你可以使用transition:指令与所需的过渡函数。以下是一个为元素添加过渡效果的示例:
<script>
import { fade } from 'svelte/transition';
</script>
<div transition:fade>some text here</div>
在前面的代码片段中,我们从svelte/transition中导入了fade并将其应用于<div>元素。
你会看到,在前面代码中,当<div>元素挂载到 DOM 上时,<div>元素将平滑地淡入。当<div>元素从 DOM 卸载时,<div>元素将平滑地淡出。
transition: 指令设置元素挂载到 DOM 和从 DOM 卸载时播放的转换。如果你想更精细地控制元素挂载或卸载时播放的转换,可以使用 in: 和 out: 指令:
<script>
import { fade, blur } from 'svelte/transition';
</script>
<div in:fade out:blur>some text here</div>
在前面的代码片段中,我们应用了 fade 作为进入转换,blur 作为退出转换。当 <div> 元素挂载到 DOM 上时,<div> 元素将平滑地淡入。当 <div> 元素从 DOM 中卸载时,<div> 元素将平滑地模糊退出。
因此,transition: 指令本质上是对 in: 和 out: 转换的简写。换句话说,以下片段中应用于两个元素的转换在功能上是相同的:
<div transition:blur>some text here</div>
因此,前面的代码片段类似于以下代码片段:
<div in:blur out:blur>some text here</div>
从前面的示例中,我们已经看到了 Svelte 的两个内置转换,fade 和 blur - 让我们看看更多!
Svelte 的内置转换
Svelte 的内置转换是从 svelte/transition 模块导出的。
以下列表提供了 Svelte 内置转换的概述:
fade: 这个转换平滑地使元素淡入或淡出,随时间调整其不透明度
blur: blur 转换逐渐在元素上应用或移除模糊效果
slide: slide 转换使元素平滑地进入或退出视图
fly: fly 转换使元素平滑地从指定的 x 和 y 偏移量平移
scale: 这个转换使元素在出现或消失时大小增长或缩小
draw: draw 转换在 SVG 路径上创建绘制或擦除效果
在浏览内置转换列表时,你可能注意到其中一些转换依赖于用户指定的值。例如,fly 转换依赖于元素在转换进入时应该飞出的指定 x 和 y 偏移量。
自定义转换
要使用这些转换及其所需的值,你可以传递一个包含必要属性的配置对象到转换指令中:
<script>
import { fly } from 'svelte/transition';
</script>
<div transition:fly={{ x: 200, y: 100 }}>Content goes here</div>
在前面的代码片段中,我们应用了带有指定 x 和 y 偏移量的 fly 转换,这表示元素将从右侧 200 像素和下方 100 像素的位置飞入。通过提供适当的值,你可以在你的 Svelte 组件中实现一系列定制的转换效果。
当你想要元素在转换退出时飞到不同的位置,而不是转换进入时飞入的位置时,这种方法尤其有用。
你可以不使用 transition: 指令,并为进入和退出转换只提供一个配置,而是将其分开为 in: 和 out: 指令,并将不同的配置对象传递给每个指令。
以下代码片段展示了这一点的示例:
<script>
import { fly } from 'svelte/transition';
</script>
<div in:fly={{ x: 200, y: 100 }} out:fly={{ x: -200, y: 50 }}>Content goes here</div>
<div> 元素从右侧 200 像素和下方 100 像素飞入,同时从左侧 200 像素和下方 50 像素飞出。通过将 transition: 指令分为 in: 和 out: 指令,您可以使用不同的配置对象控制进入和退出转换,在 Svelte 组件中实现更复杂的转换效果。
除了每个转换的特定自定义配置外,Svelte 的所有内置转换都接受 delay、duration 和 easing 作为转换配置的一部分。这些参数允许您控制动画的时间,为设计用户界面提供更大的灵活性。
delay 参数确定转换开始前的等待时间,而 duration 参数指定转换持续的时间。通过修改这些值,您可以协调转换的开始时间和每个转换的持续时间,创建更复杂和吸引人的动画。
这是一个调整 fade 转换的 delay 和 duration 值的示例:
<script>
import { fade } from 'svelte/transition';
</script>
<div transition:fade=fade transition will start with a 500 ms delay after it is mounted onto the DOM, and the transition will last for 1000 ms.
On the other hand, `easing` is a function that is responsible for controlling the pacing of the animation. By adjusting the `easing` function, you can create animations that start slow and end fast, start fast and end slow, or follow a custom pattern, giving you even more control over the look and feel of your animations.
Svelte comes with a collection of built-in `easing` functions, which can be imported from `svelte/easing`. These `easing` functions can then be applied to transitions, as seen in the following code:
练习 - 发现 Svelte 的内置转换
作为练习,尝试访问官方 Svelte 文档,并识别每个内置转换的可配置属性列表。
为了让您开始,这里有一份 Svelte 内置转换的列表:
fade
blur
fly
slide
scale
draw
我们知道转换在元素挂载或从 DOM 中卸载时播放,但转换究竟何时以及如何播放?
让我们在下一节中探讨转换播放的时间和方式。
转换何时播放?
Svelte 中的转换在元素被添加或从 DOM 中移除时播放。
in: 转换在元素被添加到 DOM 时执行。这通常发生在组件初始化时或当控制元素渲染的条件变为 true 时。
例如,在一个 {#if} 块中,当 if 条件从假变为真时,该 {#if} 块内的元素会被添加到 DOM 中。所有应用于这些元素的 in: 转换将在元素被插入 DOM 后立即 同时播放:
{#if condition}
<div in:fade>some content</div>
<div transition:blur>more content</div>
{/if}
在前面的代码片段中,当 condition 变为 true 时,两个 <div> 元素将被插入到 DOM 中。一旦两个 <div> 元素都被插入,fade 和 blur 转换将立即同时开始播放。fade 和 blur 转换是否同时结束取决于每个转换指定的持续时间。
相反,当元素从 DOM 中移除时,会执行out:过渡。这可以发生在组件被销毁时,或者当控制元素渲染的条件变为false时。
过渡在元素被安排从 DOM 中移除时立即开始。过渡完成后,元素将从 DOM 中移除。
让我们用一个例子来说明这一点:
{#if condition}
<div out:fade>some content</div>
<div transition:blur>more content</div>
{/if}
在前面的代码片段中,当condition变为false时,两个<div>元素仍然保留在 DOM 中,尽管条件不再为真。这是因为需要在从 DOM 中移除这两个<div>元素之前,对它们执行out:过渡。如果立即从 DOM 中移除<div>元素,它们将不再对用户可见,使得任何后续的out:过渡无效且不可见。
fade和blur过渡将同时应用于两个<div>元素作为out:过渡。与in:过渡类似,每个过渡的持续时间取决于每个过渡指定的持续时间。
一旦所有out:过渡都播放完毕,两个<div>元素将一起从 DOM 中移除,使 DOM 状态与condition的更新值保持一致。
在之前解释何时播放in:和out:过渡的例子中,{#if}块内的所有元素都应用了过渡,导致{#if}块中的所有元素同时播放过渡。但是,如果{#if}块内不是所有元素都应用了过渡会发生什么?让我们接下来讨论这个问题。
处理混合过渡和静态元素
当一个{#if}块内的某些元素应用了过渡而其他没有时,Svelte 会根据指定的过渡对每个元素进行不同的处理。
让我们考虑一个例子:
{#if condition}
<div in:fade>Element with fade transition</div>
<div>Static element without transition</div>
<div transition:slide>Element with slide transition</div>
{/if}
在这个例子中,当condition变为true时,应用了过渡的元素将随着它们被插入 DOM 而动画化,而没有任何过渡的静态元素将简单地出现,没有任何动画。
根据前面的代码片段,第二个<div>元素将立即插入并显示在 DOM 上,因为第一个和第三个<div>元素分别淡入和滑动进入。
同样,当condition变为false时,具有out``:过渡的元素(在这种情况下,只有第三个<div>元素,因为transition:指令暗示了in:和out:过渡)将播放它们各自的退出过渡。
根据前面的代码片段,你会看到第一个和第二个<div>元素保持不变,并且第三个<div>元素上播放了幻灯片过渡。在{#if}块内的所有元素只有在所有out:过渡完成后才会一起从 DOM 中移除。
总结来说,当你在同一个逻辑块内混合具有和没有过渡效果的元素时,所有元素将同时被添加到和从 DOM 中移除。Svelte 只对应用了过渡效果的元素进行动画处理,而没有过渡效果的静态元素将无动画地被插入或移除。
到目前为止,我们只看到了使用{#if}块作为添加或删除元素手段的例子,但 Svelte 中还有其他逻辑块也可以使用。
让我们看看它们是什么。
其他 Svelte 逻辑块用于过渡
{#if}块根据if条件添加或删除元素。除了{#if}块之外,Svelte 中还有其他逻辑块提供了在添加或删除元素时应用过渡的机会,例如{#each}、{#await}和{#key}。这些块也可以对它们包含的元素应用过渡,为你的用户界面动画提供了广泛的可能性。
例如,{#each}块用于遍历项目列表并为每个项目渲染元素。你可以以与{#if}块类似的方式在{#each}块内对元素应用过渡。让我们看看一个例子:
{#each items as item (item.id)}
<div in:fade out:slide>{item.name}</div>
{/each}
在这个例子中,当向items数组中添加或删除新项目时,{#each}块内的元素将执行各自的in:和out:过渡。当items数组中有新项目时,新的<div>元素将淡入列表的末尾。当从items数组中删除元素时,相应的<div>元素将从列表中滑动出去。在列表中使用过渡可以创建动态且引人入胜的用户体验,当向列表中添加或删除项目时,提供清晰的视觉提示。
类似地,你可以使用{#await}和{#key}块与过渡结合,在各种场景中管理元素的添加和删除,同时创建视觉上吸引人的动画。
transition:, in:, 和 out: 指令可以应用于任何元素,并且同一个逻辑块内的元素将同时被添加或移除。这同样适用于嵌套的逻辑块。
例如,让我们考虑以下代码片段:
{#if condition}
<p transition:blur>paragraph 1</p>
{#each items as item (item.id)}
<div transition:fade>{item.name}</div>
{/each}
<p>paragraph 2</p>
{/if}
当condition从false变为true时,以下情况会发生:
第一个具有blur过渡效果的<p>元素将随着其被插入到 DOM 中进行动画处理
同时,对于items数组中的每个项目,当它们被插入到 DOM 中时,具有fade过渡效果的<div>元素将进行动画处理
最后一个没有过渡效果的<p>元素将简单地出现在 DOM 中,没有动画
相反,当condition从true变为false时,以下情况会发生:
第一个具有blur过渡效果的<p>元素将进行动画
同时,对于items数组中的每个项目,具有fade过渡效果的<div>元素将进行动画处理
最后一个没有过渡的<p>元素将保持不变
一旦<p>元素中所有过渡和{#each}块中所有<div>元素中的过渡都已完成,<p>和<div>元素将一起从 DOM 中删除
通过结合使用过渡和嵌套逻辑块,你可以创建复杂的动画,从而提升用户体验。
默认情况下,过渡只有在最近的逻辑块导致元素添加或删除时才会播放。然而,我们可以通过global修饰符来改变这种行为。
全局修饰符
只有当最近的逻辑块导致元素添加或删除时才会播放过渡,这有助于限制同时动画的数量,使用户体验更加专注且不那么令人不知所措。这被称为本地模式;也就是说,过渡仅应用于本地更改。
要改变这种行为,我们可以应用global修饰符。当global修饰符应用于transition:, in:, 和 out:指令时,确保动画在元素被添加或删除时播放。
要应用global修饰符,只需在指令后缀加上|global,如下所示:
{#if condition}
<div in:fade|global>some text here</div>
{/if}
根据前面的例子,在应用global修饰符之前,fade动画只有在最近的逻辑块,即{#if}块触发<div>元素的插入或删除时才会播放。这意味着如果另一个父逻辑块导致了元素的添加或删除,动画将不会播放。有了global修饰符,过渡将在<div>元素被添加或删除时播放,无论哪个逻辑块导致它。
为了进一步阐述,让我们看看以下嵌套的{#if}块示例:
{#if condition1}
<div transition:fade>first div</div>
{#if condition2}
<div transition:fade>second div</div>
<div transition:fade|global>third div</div>
{/if}
{/if}
让我们从condition1为false和condition2为true开始。
当condition1变为true时,三个<div>元素将一起插入到 DOM 中。由于condition2始终为true,此时,导致所有<div>元素插入的{#if}块是带有condition1的那个。
第一个<div>元素将淡入,因为其最近的逻辑块,即{#if condition1},负责插入<div>元素。
第二个<div>元素将立即在屏幕上可见,而不会播放fade过渡。这是因为,默认情况下,过渡处于本地模式,其最近的逻辑块,即{#if condition2},不是导致在此点插入<div>元素的原因。
第三个<div>元素将与第一个<div>元素同时淡入。因为<div>元素对其过渡应用了|global修饰符,所以负责其插入的逻辑块无关紧要。过渡将播放,无论哪个具体的逻辑块导致<div>元素被插入。
现在假设condition1从true变为false呢?
同样的逻辑适用;因此,第二个<div>元素将保持不变,只有第一个和第三个<div>元素会渐隐。一旦渐隐过渡完成,所有三个<div>元素都将从 DOM 中移除。
在我们已走过的场景中,第二个<div>元素的fade过渡尚未播放。那么,第二个<div>元素的fade过渡将在何时播放?
要理解第二个<div>元素的fade过渡将在何时播放,让我们考虑condition1保持true且condition2从false变为true的情况。
当condition1为true且condition2从false变为true时,第二个<div>元素将被插入到 DOM 中。由于它最近的逻辑块{#if condition2}现在负责插入,transition:fade过渡将被播放。
如您所见,使用global修饰符,我们可以改变过渡的播放时机以响应变化。我们不仅可以只在元素受特定条件影响时播放过渡,还可以将其改为始终播放。
Svelte 3 和 Svelte 4 之间的区别
如我们之前所解释的,Svelte 过渡默认是本地模式。然而,这仅在 Svelte 4 中有所改变。在 Svelte 3 中,情况正好相反。在 Svelte 3 中,过渡默认是全局模式,你需要将local修饰符应用到过渡上才能将其改为本地模式。
到目前为止,我们已经介绍了如何使用transition:, in:, 和 out:指令给元素添加过渡,我们学习了何时以及如何播放过渡。在我们结束这一章之前,让我们深入了解 Svelte 中过渡的内部工作原理,以便更好地理解其机制。
Svelte 过渡的内部工作原理
在我们深入探讨 Svelte 过渡的内部工作原理之前,让我们首先简要讨论在网页上创建动画的一般方法。理解这些基本概念为掌握 Svelte 过渡的工作原理提供了坚实的基础。
通常,你可以使用 CSS 或 JavaScript 来创建动画。
使用 CSS 创建动画
要使用 CSS 创建动画,你可以使用 CSS 的animation属性以及@keyframes规则。@keyframes规则用于定义一系列样式,指定动画过程中每个关键帧(从 0%到 100%)的 CSS 样式。
例如,看看这个:
@keyframes example {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.75);
}
}
在前面的代码片段中,我们定义了一个名为example的动画关键帧,它同时将不透明度从 100%变为 0%,并将缩放从 1 变为 1.75。
要将example动画应用到元素上,我们使用 CSS 的animation属性:
<div style="animation: example 4s 1s 1;">Animated element</div>
在前面的代码片段中,我们将动画设置为名为example的动画关键帧,持续时间为四秒,延迟为一秒,并且只播放动画一次。
@keyframes 规则非常灵活。我们可以通过 @keyframes 声明对动画序列的中间步骤进行精细控制。结合 animation 属性,我们可以控制动画的外观,以及动画何时开始和持续多长时间。
使用 CSS 创建动画的优点是它不涉及 JavaScript,浏览器可以自行优化 CSS 动画。这节省了 JavaScript 带宽,因此即使有密集的 JavaScript 任务同时运行,动画也可以平滑运行。这确保了更好的性能和用户体验,因为即使在重处理负载下,动画也能保持响应和流畅。
使用 JavaScript 创建动画
使用 JavaScript 创建动画涉及动态操作 DOM 元素的样式和属性。
例如,让我们用 JavaScript 编写一个淡入动画。
要实现这一点,我们需要逐渐将元素的透明度从 0 变更到 1。要在 JavaScript 中将 <div> 元素的透明度设置为 0,我们直接通过元素的 style.opacity 属性来设置:
div.style.opacity = 0;
在前面的代码片段中,我们假设我们已经获得了对 <div> 元素的引用,并将其存储在名为 div 的变量中。然后,我们通过 div 变量将 <div> 元素的透明度设置为 0。
要将元素的透明度从一个值动画过渡到另一个值,您需要在指定的时间间隔内定期更新样式。
与通过 setInterval 设置固定间隔不同,更新样式的间隔通常是通过使用 requestAnimationFrame 方法来实现的。requestAnimationFrame 是一个浏览器方法,它通过在下次重绘之前调用指定的函数来优化动画性能。requestAnimationFrame 通过允许浏览器确定最佳时间来更新样式,避免了不必要的操作或重复的重绘,从而帮助确保动画运行得既平滑又高效。
这里是一个使用 requestAnimationFrame 创建动画的例子:
let start;
const duration = 4000; // 4 seconds
function loop(timestamp) {
if (!start) start = timestamp;
// get the progress in percentage
const progress = (timestamp – start) / duration;
// Update the DOM element's styles based on progress
if (progress > 1) {
div.style.opacity = 0;
div.style.transform = 'scale(1.75)';
} else {
div.style.opacity = 1 – progress;
const scale = 1 + progress * 0.75;
div.style.transform = `scale(${scale})`;
// continue animating, schedule the next loop
requestAnimationFrame(loop);
}
}
// Start the animation
requestAnimationFrame(loop);
在前面的代码片段中,我们安排在下一个动画帧中调用 loop 函数,直到进度完成。我们计算 progress 为动画总持续时间内经过的时间百分比。有了 progress 的值,我们计算 <div> 元素的透明度和缩放比例。
在这个例子中,使用 JavaScript 和 requestAnimationFrame 创建动画的最终结果与上一节中使用 CSS 动画的例子所达到的最终结果相同。
<div> 元素在动画开始时的透明度为 1,缩放为 1,在动画结束时透明度为 0,缩放为 1.75。
使用 JavaScript 进行动画提供了对动画逻辑的更多控制,使您能够创建复杂的交互式动画,这些动画可以响应用户输入或其他事件。
然而,使用 JavaScript 进行动画的一个缺点是,随着动画的依赖,它可能需要更多的资源,因为动画依赖于浏览器的 JavaScript 引擎来处理和执行动画逻辑。
现在我们已经了解了在网络上创建动画的两种不同方法,Svelte 过渡使用的是哪一种?
答案是两者都是。
在 Svelte 中动画化过渡
尽管 Svelte 的所有内置过渡都使用 CSS 进行动画处理,但 Svelte 能够使用 CSS 和 JavaScript 两种方式来动画化过渡。
要通过 CSS 动画化过渡,Svelte 将为每个元素生成一个一次性的@keyframes规则,基于过渡和指定的配置对象。
让我们以fly过渡为例:
<script>
import { fly } from 'svelte/transition';
</script>
<div in:fly={{ x: 50, y: 30 }}>Some text here</div>
在前面的代码片段中,fly过渡被应用于一个<div>元素。作为回应,Svelte 生成一个类似下面的@keyframes规则:
@keyframes fly-in-unique-id {
0% {
transform: translate(50px, 30px);
opacity: 0;
}
100% {
transform: translate(0, 0);
opacity: 1;
}
}
生成的@keyframes规则将在过渡期间应用于元素。关键帧名称中的unique-id部分确保每个生成的关键帧都是唯一的,并且不会与其他元素冲突。
根据过渡的指定duration和delay,Svelte 将计算动画的适当时间,并将生成的@keyframes规则应用于元素,使用 CSS 的animation属性。然后,元素将根据指定的transition、duration和delay进行动画化。
例如,在下面的代码片段中,我们有一个fly过渡被应用于一个持续时间为 500 毫秒、延迟时间为 200 毫秒的<div>元素:
<script>
import { fly } from 'svelte/transition';
</script>
<div in:fly={{ x: 50, y: 30, duration: 500, delay: 200 }}>Some text here</div>
要在前面代码片段中动画化fly过渡,Svelte 将生成相应的关键帧动画,并将生成的关键帧动画应用于具有指定持续时间和延迟的元素:
div.style.animation = 'fly-in-unique-id 500ms 200ms 1';
同样,也可以使用 JavaScript 来动画化过渡。
Svelte 将通过requestAnimationFrame调度一个循环,在整个指定持续期间运行动画。
我们现在不会深入探讨requestAnimationFrame循环与动画的具体工作原理。在下一章中,我们将探讨如何使用 JavaScript 创建自定义过渡,这将更深入地了解requestAnimationFrame循环如何与动画交互,以及如何有效地利用它来实现平滑、引人入胜的过渡。敬请期待,了解更多关于如何使用 Svelte 制作独特动画的知识。
摘要
在本章中,我们学习了如何将过渡添加到元素上。我们探讨了transition:, in:, 和 out:指令,以及如何自定义它们。
随后,我们探讨了何时以及如何播放过渡。我们讨论了当元素既有过渡又有无过渡时,过渡是如何播放的,以及当过渡用于嵌套逻辑块内的元素时,过渡是如何播放的。
最后但同样重要的是,我们深入探讨了 Svelte 如何播放过渡动画。
借助这些知识,你现在可以自信地将过渡应用到 Svelte 中的元素上。这将使你能够增强应用程序的交互性和视觉吸引力,从而提供更吸引人的用户体验。
在下一章中,我们将超越内置的过渡,并探讨自定义过渡的创建。
第十四章:探索自定义过渡
在本章中,我们将深入探讨 Svelte 中的自定义过渡。到目前为止,我们已经探讨了 Svelte 的内置过渡以及如何使用它们创建引人入胜和动态的用户界面。然而,可能存在内置过渡无法完全满足你的要求,而你希望创建更独特的东西的情况。这就是自定义过渡发挥作用的地方。
自定义过渡允许你完全控制你希望在 Svelte 应用程序中实现的动画和效果。本章将指导你创建自己的自定义过渡,无论它们是基于 CSS 还是 JavaScript。我们将探讨过渡合约,它是创建自定义过渡的基础,并提供实际示例以帮助你入门。
到本章结束时,你将牢固地理解如何在 Svelte 中创建自定义过渡,并且你将具备在项目中实现它们的知识,将你的用户界面提升到新的水平。
本章包括以下主题的章节:
过渡合约
使用css函数编写自定义 CSS 过渡
使用tick函数编写自定义 JavaScript 过渡
技术要求
本章将包含大量的代码,但请放心——你可以在 GitHub 上找到本章中使用的所有代码示例,网址为github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter14。
过渡合约
在我们深入创建自定义过渡之前,了解它们建立的基础是至关重要的:过渡合约。
如果你已经阅读了第九章,你将熟悉存储合约的概念。正如存储是一个遵循特定存储合约的对象一样,过渡是一个遵循过渡合约的函数。通过理解和遵守这些合约,你可以创建与 Svelte 内置过渡系统无缝集成的自定义过渡。
过渡合约由一个负责过渡的单个函数组成。这个函数接受两个参数:
节点:应用过渡的目标 DOM 元素
params:包含配置选项的对象
函数应返回一个描述如何执行过渡的对象。我们将在本节稍后深入探讨这个返回对象的细节。
这里是一个遵循过渡合约的自定义过渡示例:
function customTransition(node, params) {
const config = { ... };
return config;
}
在前面的代码片段中,我们创建了一个名为customTransition的自定义过渡。我们通过声明一个接受两个参数的customTransition函数来实现这一点:node和params。然后,这个函数返回一个对象——我们将称之为config——它描述了过渡。
为了将我们刚刚创建的自定义过渡与 Svelte 中过渡的使用联系起来,这里我们看到 customTransition 函数是如何应用于 <div> 元素的:
<div transition:customTransition={{ duration: 500 }}>some text</div>
当 <div> 元素被插入或即将从 DOM 中移除时,Svelte 将尝试播放过渡。Svelte 通过调用 customTransition 函数并传递 <div> 元素和传递给过渡的 config 对象来实现这一点:
const config = customTransition(div, { duration: 500 });
由 customTransition 返回的此 config 对象将决定过渡如何播放。
现在,让我们关注自定义过渡函数返回的 config 对象的要求。
从自定义过渡返回的 config 对象应至少包含以下属性或方法之一:
delay: 以毫秒为单位的数字。这指定了在过渡开始之前需要等待多长时间。
duration: 以毫秒为单位的数字。指定过渡播放的持续时间。这决定了动画对用户来说看起来有多快或多慢。
easing: 用于缓动的函数。此函数确定过渡进度随时间变化的速率。
css: 一个带有两个参数的函数:progress 和 remaining。在这里,progress 是一个介于 0 和 1 之间的值,表示过渡的进度,而 remaining 参数的值等于 1 - progress。
此函数应返回一个包含要应用于目标 DOM 元素的 CSS 样式的字符串。
tick: 一个在过渡期间重复调用的函数,带有两个参数:progress 和 remaining。在这里,progress 是一个介于 0 和 1 之间的值,表示过渡的进度,而 remaining 参数的值等于 1 - progress。
此函数可用于根据当前进度更新 DOM 元素的样式。
这是一个遵循过渡契约的更完整的自定义过渡示例:
import { cubicInOut } from 'svelte/easing';
function customTransition(node, params) {
return {
duration: 1000,
delay: 500,
easing: cubicInOut,
css: (progress) => `opacity: ${progress}`,
};
}
在前面的代码片段中,我们的自定义过渡(命名为 customTransition)返回一个对象,描述了过渡的 duration、delay、easing 和 css 样式。
在上一章中,当我们将过渡应用于元素时,我们看到了 delay、duration 和 easing。由于这些属性的行为在此上下文中保持不变,让我们关注一些新的内容:css 和 tick 函数。
css 函数
如您可能记得,在上一章的最后部分,Svelte 过渡在底层是如何工作的,Svelte 使用 CSS 和 JavaScript 的组合来动画化过渡。它利用 CSS @keyframe 规则以及 CSS 动画的 animation 属性和 JavaScript 动画的 requestAnimationFrame 函数。
css函数用于生成自定义过渡的 CSS @keyframe规则。如果css函数定义在自定义过渡返回的对象中,Svelte 将在元素被插入到 DOM 或即将从 DOM 中移除时调用此函数。Svelte 将根据过渡的持续时间和缓动效果,多次调用css函数,以生成适当的@keyframe规则。
css函数的第一个参数与过渡的进度相关。progress是一个介于0和1之间的数字,其中0表示元素不可见,1表示元素在屏幕上的最终位置。
例如,当元素被插入到 DOM 中后进行过渡时,progress的值从0开始,逐渐移动到1。相反,当元素在从 DOM 中移除之前进行过渡时,progress的值从1开始,逐渐移动到0。
您可以使用progress来计算创建自定义过渡所需的 CSS 样式。
例如,如果我们想创建一个将元素从透明渐变到完全可见的过渡效果,我们可以使用progress来计算过渡过程中的透明度值:
当元素不可见(progress的值为0)时,我们希望元素是透明的(opacity的值应为0)
当元素可见(progress的值为1)时,我们希望元素完全可见(opacity的值应为1)
progress与opacity之间的关系可以用图 14**.1中的图表表示:
图 14.1:进度与透明度的关系
我们将在css函数中从progress的值推导出opacity的值,如下所示:
function customTransition(node, params) {
return {
css: (progress) => `opacity: ${progress}`,
};
}
在前面的代码中应用过渡将使元素在插入 DOM 时从透明渐变到可见,并在从 DOM 中移除时渐变回透明。
让我们再举一个例子。让我们创建一个将元素从右侧飞到最终位置的过渡效果。在这里,元素的平移在整个过渡过程中发生变化,我们可以使用progress来计算平移:
当元素不可见(progress的值为0)时,我们希望元素位于右侧(translateX的值为100px)
当元素可见(progress的值为1)时,我们希望元素处于其最终位置(translateX的值为0px)
这里有一个描述progress与translateX之间关系的图表:
图 14.2:进度与 translateX 的关系
与前面的例子不同,translateX的值是progress的倒数:当progress为0时,translateX有一个非零值;当progress为1时,translateX变为0。
因此,为了计算translateX的值,我们使用1 – progress乘以一个值,如以下代码片段所示:
function customTransition(node, params) {
return {
css: (progress) => `transform: translateX(${(1 – progress) * 100}px)`,
};
}
当应用前面的代码片段中的 customTransition 函数到元素上时,随着元素被添加到 DOM 中,该元素将从右侧飞入其最终位置。并且由于计算 progress 的倒数(1 – progress)是如此常见,这个值被作为 css 函数的第二个参数提供。
因此,这里再次是我们的自定义转换,但这次使用第二个参数来计算平移:
function customTransition(node, params) {
return {
css: (progress, remaining) => `transform: translateX(${remaining * 100}px)`,
};
}
css 函数返回一个可以包含多个 CSS 声明的 CSS 字符串。你用分号分隔每个 CSS 声明,就像在元素的 style 属性中做的那样。例如,让我们创建一个同时实现淡入和从右侧平移的转换:
function customTransition(node, params) {
return {
css: (progress, remaining) => `opacity: ${progress}; transform: translateX(${remaining * 100}px); `,
};
}
在前面的代码片段中,我们同时实现了淡入和从右侧平移。返回的 CSS 字符串包含多个用分号分隔的 CSS 声明,一个用于 opacity,另一个用于 transform,这些声明将在转换期间应用。
现在我们已经介绍了 css 函数,让我们来看看 tick 函数。
tick 函数
tick 函数是创建自定义转换的 css 函数的替代品。与用于生成动画 CSS @keyframe 规则的 css 函数不同,tick 函数允许你使用 JavaScript 动画化转换。这可以提供对转换的更精细控制,从而创建出仅使用 CSS 难以实现的更复杂的动画。
tick 函数在通过 requestAnimationFrame 进行转换期间被反复调用。与 css 函数类似,tick 函数接受两个参数:progress 和 remaining。progress 参数是一个介于 0 和 1 之间的值,其中 0 表示元素处于不可见状态,而 1 表示元素位于屏幕上的最终位置,而 remaining 参数等于 1 – progress。这些参数可以根据转换的当前进度修改应用到的 DOM 元素。
例如,如果我们想使用 tick 函数创建淡入转换,可以根据进度值更新元素的透明度,如下所示:
function customTransition(node, params) {
return {
tick: (progress) => {
node.style.opacity = progress;
},
};
}
根据前面的代码片段,Svelte 在转换过程中的每一帧都会触发 tick 函数。
当元素开始出现时,progress 值为 0,我们使用这个 progress 值将元素的初始 opacity 值设置为 0。
随着转换的进行,tick 函数会使用介于 0 和 1 之间的 progress 值被调用,并且我们会根据 progress 值更新元素的 opacity 值。
在转换结束时,tick 函数最后一次使用 progress 值为 1 被调用。此时,我们将元素的 opacity 值设置为最终的 1。
代码片段中的tick函数与使用css函数创建的自定义淡入过渡操作类似。两种方法都在整个过渡过程中修改元素的opacity值。关键区别在于它们的执行方式。
Svelte 在过渡开始时多次调用css函数,并使用不同的进度值来构建 CSS 的@keyframe规则。一旦完成,css函数在过渡期间不再被调用。新创建的 CSS@keyframe规则随后通过 CSS 的animation属性应用于元素。然后通过 CSS 更新元素的opacity值。
另一方面,tick函数在过渡的每个动画帧中由 Svelte 多次调用。在每次tick调用中,元素的opacity值通过 JavaScript 进行修改。
现在我们已经了解了过渡合约,让我们利用这些知识来创建更多自定义过渡。
使用css函数编写自定义 CSS 过渡
我们将要一起尝试编写的第一个自定义过渡是一种在演示中经常看到的效果,通常被称为“颜色滑动”。这种效果因其动态的颜色扫动而突出,这种扫动在屏幕上流动,创造出一种吸引观众注意力的能量感。
如其名所示,颜色滑动过渡涉及在对象上发生的颜色扫动变化。
想象一下:你正在查看一个静态屏幕,可能是网站的一部分。突然,一种新颜色从屏幕的一边开始显现。就像波浪一样,这种颜色在屏幕上扩散,包围了整个屏幕。一旦颜色完全覆盖了屏幕,它开始从起始边缘退去,揭示新的内容。当颜色完全消失后,新的内容完全展现出来:
图 14.3:颜色滑动过渡
滑动可以从任何方向移动——它可以水平地从左到右移动,垂直地从上到下移动,甚至可以斜向移动。
我们将修改颜色滑动过渡,使其应用于段落(<p>)元素。当<p>元素被添加到 DOM 中时,一道颜色波将扫过它,在过渡完成后揭示<p>元素内的文本。当<p>元素从 DOM 中移除时,过渡将反向播放,在过渡完成后隐藏文本。
过渡的视觉演示可以在这里看到:
图 14.4:段落上的颜色滑动过渡
在本节中,我们将逐步介绍如何使用 Svelte 创建这种迷人的颜色滑动过渡。
由于当<p>元素从 DOM 中移除时隐藏文本的过渡与当<p>元素被添加时显示文本的过渡相同,但播放方向相反,因此我们将关注当<p>元素被添加到 DOM 时播放的过渡。这是因为当过渡应用于一个元素时,Svelte 会在元素被移除时播放相同的过渡,但方向相反。因此,通过关注元素被添加到 DOM 时播放的过渡,我们实际上覆盖了两种情况。
因此,让我们开始创建一个过渡。
首先,让我们创建我们自定义过渡的结构。回想一下过渡合约——一个过渡是一个返回描述过渡的对象的函数:
<script>
function colourSwipe(node) {
// TODO: implement the transition here
const config = {};
return config;
}
</script>
<p transition:colourSwipe>Some text here</p>
在前面的代码片段中,我们创建了一个colourSwipe过渡并将其应用于<p>元素。我们当前的任务是实现colourSwipe过渡,通过填充config对象来完成。
我们将要添加到config对象的前两个字段是duration和delay。如以下代码片段所示,我们将过渡的持续时间设置为 1 秒,并且过渡将没有延迟开始:
function colourSwipe(node) {
const config = {
duration: 1000,
delay: 0,
};
return config;
}
然而,在创建自定义过渡时,通常你可能希望允许过渡的使用者根据过渡应用的位置来自定义持续时间和延迟。
例如,一个过渡的使用者可能希望通过在transition:指令中指定它们来有一个 200 毫秒的延迟和 2 秒的持续时间,如下面的代码片段所示:
<div transition:colourSwipe={{ delay: 200, duration: 2000 }} />
在transition:指令中指定的这些自定义延迟和持续时间将作为第二个参数传递给colourSwipe过渡,它将在config对象中使用它们:
function colourSwipe(node, params) {
const config = {
duration: params?.duration ?? 1000,
delay: params?.delay ?? 0,
};
return config;
}
在前面的代码片段中,我们使用config对象中的params.duration和params.delay的值,并在这些参数没有明确指定时提供默认值。
既然我们已经指定了过渡的delay和duration字段,让我们将注意力转向下一个字段——easing。
我们将使用linear(线性)缓动,使过渡以恒定速度移动,没有任何加速或减速。就像我们对duration或delay所做的那样,我们将使easing可由用户自定义。因此,在以下代码片段中,我们根据用户指定的缓动设置easing的值。如果它被省略,我们将回退到我们的默认缓动——linear缓动:
import { linear } from 'svelte/easing';
function colourSwipe(node, params) {
const config = {
duration: params?.duration ?? 1000,
delay: params?.delay ?? 0,
easing: params?.easing ?? linear,
};
return config;
}
通常情况下,在创建自定义过渡的过程中,duration(持续时间)、delay(延迟)和easing(缓动函数)字段是最容易设置的。大多数情况下,我们会确定默认的duration、delay和easing值,然后允许用户根据自己的喜好调整这些值。
在确定了duration、delay和easing值之后,我们现在深入过渡的核心:为过渡元素编写 CSS。
如果你仔细观察过渡,你会注意到过渡可以分为两个不同的阶段:前半部分涉及颜色块扩展以包围整个<p>元素,而后半部分则对应颜色块收缩以揭示<p>元素内的文本:
图 14.5:颜色滑动过渡分为两个部分,由虚线分隔
让我们探索如何创建这些 CSS 规则,以有效地实现过渡的各个阶段。
在开始之前,让我们在我们的colourSwipe过渡中创建一个css函数:
function colourSwipe(node, params) {
const config = {
duration: params?.duration ?? 1000,
delay: params?.delay ?? 0,
easing: params?.easing ?? linear,
css: (progress) => {}
};
return config;
}
我们将在前面的代码片段中填充css函数。
需要注意的关键点是,progress值在过渡开始时从0开始,并在过渡结束时达到1。由于我们将过渡分为两个阶段,第一阶段将看到progress值从0移动到0.5,而第二阶段则从0.5推进到1。
因此,在我们的css函数中,我们将为过渡的不同阶段实现不同的 CSS 规则:
css: (progress) => {
if (progress <= 0.5) {
} else {
}
}
在前面的代码片段中,你可以看到我们在css函数中添加了一个if块,该块使用progress值来确定要应用哪些 CSS 规则集。对于过渡的前半部分(progress <= 0.5),实现了第一组 CSS 规则。对于后半部分(progress > 0.5),使用第二组规则。这样,我们可以在过渡的两个阶段以不同的方式定制元素的外观。
在我们的过渡的前半部分,我们需要创建一个增长的颜色块。为了创建这个,我们将在元素的背景上应用一个线性渐变。渐变将从实心颜色过渡到透明颜色。通过将实心颜色和透明颜色的颜色停止点对齐在相同的位置,我们可以在渐变过渡中创建一个尖锐的硬线。
例如,如果我们想要一个占据元素左侧 25%的实心红色颜色块,我们可以应用以下 CSS:
background: linear-gradient(to right, red 0, 25%, transparent 25%);
在前面的代码片段中,我们有一个从左到右移动的线性渐变,红色和透明颜色在 25%的位置共享相同的颜色停止点。这就在渐变的左侧 25%处创建了一个实心红色块,而剩余的 75%是透明的。
我们选择使用线性渐变来实现这个颜色块,而不是叠加另一个元素,这显示了这种方法的高效性。它消除了创建额外元素的需要。
在背景上设置线性渐变而不是调整元素大小的好处是,可以在不实际调整元素大小的情况下产生调整颜色块的效果,这避免了在 DOM 中引起重新布局和布局偏移。这样,应用了 CSS 的元素在整个过渡过程中保持在其原始位置和大小不变。
因此,现在我们已经确定了要使用的 CSS,让我们将其纳入我们过渡的css函数中。
在此之前,我们需要做一些数学计算。我们打算使用 progress 的值来计算实色块应覆盖的元素百分比。
在过渡的第一半中,progress 的值从 0 到 0.5。
在这个过渡阶段,需要覆盖的元素百分比应从 0% 到 100%。
因此,通过进行算术计算,我们可以得出结论,百分比值是 progress 值的 200 倍:
const percentage = progress * 200;
现在我们将这个整合到我们的 css 函数中:
css: (progress) => {
if (progress <= 0.5) {
const percentage = progress * 200;
return `background: linear-gradient(to right, red 0, ${percentage}%, transparent ${percentage}%);`;
} else {
}
}
在前面的代码片段中,我们使用计算出的百分比来控制实色块的大小,使其在过渡的第一半中从左侧的 0% 增长到 100%。
现在,让我们将注意力转向过渡的第二部分,其中实色块从元素的全宽向右边缘收缩。
另一种设想这个问题的方法是考虑从左侧边缘开始的透明部分的扩展,覆盖从 0% 到 100% 的元素。这反映了过渡的第一半,关键区别在于现在增长到完全包围元素的是透明颜色,而不是实色。
计算百分比值的公式保持不变,但由于 progress 值现在从 0.5 到 1 变化,我们需要在将其乘以 200 之前从 progress 值中减去 0.5。因此,方程变为这个:
const percentage = (progress – 0.5) * 200
通过这个修改,我们的 css 函数现在变成这样:
css: (progress) => {
if (progress <= 0.5) {
const percentage = progress * 200;
return `background: linear-gradient(to right, red 0, ${percentage}%, transparent ${percentage}%);`;
} else {
const percentage = (progress – 0.5) * 200;
return `background: linear-gradient(to right, transparent 0, ${percentage}%, red ${percentage}%);`;
}
}
在这个更新的函数中,实色块和透明区域在过渡过程中根据计算出的百分比动态调整大小,有效地创造出颜色滑动的视觉错觉。
将过渡应用到元素上时,你可能会注意到,尽管我们有一个功能性的实色滑动过渡效果,但有一些元素可以进一步优化以获得更平滑的视觉体验。
一个突出的问题是,元素内的文本在整个过渡过程中都保持可见。理想情况下,它应该在过渡的第一半中保持隐藏,当实色块扩展时,只有在第二半中颜色块收缩时才被揭示。
以下截图说明了这个问题:
图 14.6:在过渡的第一半中,文本没有隐藏
为了解决这个问题,我们可以在过渡的第一半中将文本颜色设置为透明,如下面的代码片段所示:
css: (progress) => {
if (progress <= 0.5) {
const percentage = progress * 200;
return `background: linear-gradient(to right, red 0, ${percentage}%, transparent ${percentage}%); color: transparent;`;
} else { /* ... */ }
}
另一个问题是在文本颜色的情况下,实色块仍然保持红色。因为我们使用 CSS 的 background 属性来创建滑动效果,所以文本保持在最前面,而实色块在背景中。
这影响了从颜色块中揭示文本的效果,因为整个文本内容在过渡的第二半部分完全可见。如果实色块与文本颜色相同,文本就会与背景融合。这将产生一种视觉错觉,给人一种文本随着颜色块收缩而被揭示的印象。
下面的截图说明了这个问题:
图 14.7:文本和块颜色不匹配
为了解决这个问题,我们需要找到一种方法来获取文本的颜色并将其融入我们的线性渐变背景中。
window.getComputedStyle() 函数允许我们获取应用到元素上的样式。我们可以使用这个函数来获取过渡开始时文本的颜色,并使用这个颜色作为我们的渐变背景:
function colourSwipe(node, params) {
const { color } = window.getComputedStyle(node);
const config = {
css: (progress) => {
if (progress <= 0.5) {
const percentage = progress * 200;
return `background: linear-gradient(to right, ${color} 0, ${percentage}%, transparent ${percentage}%); color: transparent;`;
} else {
const percentage = (progress – 0.5) * 200;
return `background: linear-gradient(to right, transparent 0, ${percentage}%, ${color} ${percentage}%);`;
}
}
};
return config;
}
在前面的修改后的代码片段中,我们将 red 替换为从 node 元素的计算样式中获取的文本颜色。
由此,我们就得到了一个定制的颜色滑动效果,作为 Svelte 过渡实现。完整的代码可以在 github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter14/01-css-transition 找到。
我们一步一步地通过使用 CSS 创建自定义 Svelte 过渡。在整个过程中,我们学习了如何将用户可定制的属性(如 duration、delay 和 easing)集成到我们的过渡中。在我们的颜色滑动过渡中,我们学习了如何构建多阶段过渡,以及如何将 progress 参数分割成各个阶段,利用其值来计算 CSS 规则,并将其应用到元素上。
希望你现在已经准备好使用 CSS 创建自己的自定义 Svelte 过渡了。
在本章的开头,我们了解到过渡合约可以包括不仅是一个 css 函数,还可以是一个 tick 函数。tick 函数允许我们在过渡期间修改元素。我们已经探讨了如何使用 css 函数创建颜色滑动过渡;在下一节中,我们将深入探讨创建另一个自定义过渡,这次使用 tick 函数。
使用 tick 函数编写自定义 JavaScript 过渡
我们在本节中尝试编写的自定义过渡是一个翻页过渡。这个过渡模仿了复古机场出发牌的机制。在这个过渡过程中,文本的每个字母都会翻转,循环遍历字符,直到最终落在正确的字母上。当所有字母都稳定在正确的字母上时,过渡结束。
下面的图示说明了翻页过渡如何揭示短语 Hello Svelte,其中垂直轴表示从上到下的时间流动:
图 14.8:翻页过渡可视化
在过渡开始时,字母从左到右开始出现,最初显示为破折号(-),然后通过随机字符翻转,最终定位到正确的字母。这种翻转动作从左到右继续,直到所有字母都与其对应的字符对齐,揭示出预期的短语。
整个过渡过程涉及修改元素内的字符,从空白状态过渡到混乱的字符,最后过渡到正确的文本。由于不需要样式或布局更改,我们没有使用css函数来实现这个过渡。tick函数是 Svelte 中实现这个过渡的完美候选者。
既然我们已经定义了翻页过渡的外观,让我们开始实现这个过渡。
建立在上一节中学习的颜色滑动过渡的基础上,翻页过渡以类似的方式开始。以下是我们的翻页过渡的基本代码结构:
<script>
function flipboard(node, params) {
const config = {
duration: params?.duration ?? 1000,
delay: params?.delay ?? 0,
easing: params?.easing ?? linear,
tick: (progress) => {
// TODO: implement the transition here
},
};
return config;
}
</script>
<p transition:flipboard>Hello Svelte.</p>
在前面的代码片段中,我们定义了一个flipboard函数,它遵循过渡契约。它接受两个参数,node和params,并返回一个过渡配置——一个描述过渡的对象。因此,我们能够将flipboard函数作为过渡应用于<div>元素上的transition:指令。
在翻页过渡中,我们已经设置了基本参数,例如定义duration、delay和easing值,同时为tick函数留出一个占位符,我们将在这里实现过渡。
要创建翻页过渡,我们首先获取将要过渡的元素的文本内容。然后,每次调用tick函数时,我们根据progress值确定要显示的文本,并相应地更新元素。
我们可以使用以下 API 检索元素的文本:
const text = node.textContent;
同样,为了设置元素的文本内容,我们通过相同的属性赋值:
node.textContent = text;
将这些整合到翻页过渡中,我们得到以下结果:
function flipboard(node, params) {
const text = node.textContent;
const config = {
// ...
tick: (progress) => {
let newText;
// TODO: compute the newText based on `text` and progress value
node.textContent = newText;
},
};
return config;
}
在前面的代码片段中,我们在flipboard函数开始播放元素上的过渡之前,在元素的开头检索文本内容。tick函数在每个动画帧上被反复调用,根据原始text值和当前的progress值计算元素的新的text值。
tick函数的任务是根据progress值确定每个字母应该如何显示。一些字母可能显示为破折号,一些可能显示为随机字符,一些可能显示为它们的原始值,而其他字母可能被隐藏。
对于每个字母,其显示取决于其在整个文本长度中的位置和当前的progress值。例如,如果一个字母位于从左边的 30%,而当前的progress值是0.5(或 50%),那么该字母应该按原样显示。
我们如何确定这些规则?是什么让我们得出刚才的结论?
我们希望元素在过渡结束时显示其所有原始字符。这意味着当progress值达到 1(或 100%)时,所有字母都应该显示其原始字符。在中间点,当progress值为0.5(或 50%)时,左侧的 50%字母应该显示其原始字符,而右侧剩余的 50%应该显示破折号、随机字符或根本不显示。
为了概括,如果一个字母的左侧位置小于progress值,它应该显示其原始字符。否则,它可能显示破折号、随机字符或根本不显示。
以下图表说明了progress值和显示文本之间的关系:
图 14.9:红色框显示在各个进度值下哪些字母显示原始字符
以下图表说明了在不同progress值下原始字符的显示方式。随着progress值的增加,单词中的更多字母会显示其原始字符。
为了实现刚才描述的翻页效果,我们将遍历每个字符,确定其相对位置,然后决定是否显示。对于位置超过当前progress值的字符,我们将显示空白空间。
这里是更新后的代码:
tick: (progress) => {
let newText = '';
for (let i = 0; i < text.length; i++) {
const position = i / text.length;
if (position < progress) {
// display the original character
newText += text[i];
} else {
// display a blank space instead
newText += ' ';
}
}
node.textContent = newText;
},
使用这段代码,翻页过渡现在根据进度值显示原始字符或空白空间。在播放过渡时,你会看到字符一个接一个地从左到右出现。
在确定了何时显示原始字符之后,让我们继续确定文本何时应该显示破折号或随机字符。
使用类似的想法,我们可以确定一个字母是否应该显示随机字符、破折号或根本不显示。我们将引入一个常数来管理这些变化的时机。如果一个字母的位置超过这个常数乘以progress值,它将显示为空白。我根据试错选择了 2 这个常数,它大于 1 但不是太大,以创建字符从左到右逐渐出现的效果。
同样,我们也可以引入另一个常数来管理破折号或随机字符的显示。如果一个字母的相对位置大于这个新常数乘以进度值,但小于 2,该字母将显示为破折号。否则,它将显示为随机字符。为此,我选择了 1.5,将其定位在 1 和 2 之间。
以下图表直观地表示了这两个常数及其对过渡的影响:
图 14.10:新文本与原始文本之间的关系
在前面的图中,你可以观察到字符在过渡过程中的变化。例如,当 progress 值为 0.4 时,位于 40% 位置的字母显示原始字符,位于 40% - 60%(progress * 1.5)的字母显示随机字符,位于 60% - 80%(progress * 2)的字母显示破折号,而超出这个范围的则不显示。
下面是我们的翻页过渡效果的更新代码:
tick: (progress) => {
let newText = '';
for (let i = 0; i < text.length; i++) {
const position = i / text.length;
if (position < progress) {
// display the original character
newText += text[i];
} else if (position < progress * 1.5) {
// display random characters
newText += randomCharacter()
} else if (position < progress * 2) {
// display dash
newText += '-';
} else {
// display a blank space instead
newText += ' ';
}
}
node.textContent = newText;
},
在前面的代码片段中,我添加了两个额外的条件来确定何时显示破折号或随机字符。
randomCharacter() 函数返回一个随机选择的字符,实现方式如下:
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890'
function randomCharacter() {
return chars[Math.floor(Math.random() * chars.length)];
}
使用这段代码,你就有了一个翻页过渡!尝试将其应用于一个元素,并观察效果。字符会一个接一个地从左到右缓慢出现,最初是破折号,然后翻转通过字符,最终稳定在正确的字符上。
你可能会注意到一个小问题:不是所有字符的宽度都相同,因此文本的整体宽度会随之增长和缩小。由于每个字母都没有与它之前的位置对齐,翻转效果可能不会立即明显。
为了解决这个问题,你可以使用等宽字体。等宽字体,也称为固定宽度字体,确保每个字母占据相同的水平空间。将等宽字体应用于元素可以增强翻转效果,使其更加视觉上突出。
例如,你可以设置字体如下:
font-family: monospace;
在本节中,我们探讨了如何创建翻页过渡,模拟了复古机场出发牌的外观。我们学习了如何根据过渡的进度来控制字符的显示,使用随机字符、破折号和原始文本。通过修改元素的文本,我们创建了一个视觉上吸引人的过渡。
本节的完整代码可以在以下链接找到:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter13。
摘要
在本章中,我们探讨了如何在 Svelte 中创建自定义过渡。我们详细介绍了两个示例,这两个示例都使用了 css 函数和 tick 函数。
希望你现在已经准备好在 Svelte 中编写自己的自定义过渡,从而能够制作出更具吸引力和独特性的用户体验。
在我们接下来的最后一章中,我们将深入探讨过渡如何影响 Svelte 应用的可访问性,指导你创建一个对所有用户都具有吸引力和包容性的体验。
第十五章:使用过渡实现无障碍
在过去两章中,我们学习了如何在 Svelte 中使用过渡。当正确使用时,过渡可以增强用户体验,引导用户的注意力,提供反馈,并为界面增添一层光泽。然而,对于患有前庭功能障碍的用户来说,这些动画可能会感到不适,甚至可能造成伤害。因此,在创造引人入胜的动画和确保它们不会对有特定需求的用户产生负面影响之间取得平衡是至关重要的。
在本章中,我们将深入探讨使网络过渡对患有前庭功能障碍的用户更加无障碍的技术,探索尊重用户对运动偏好的 CSS 和 JavaScript 方法。
到本章结束时,你将更好地理解网络无障碍以及如何创建更包容的 Web 应用程序,以满足所有用户的具体需求或偏好。
本章将涵盖以下内容:
什么是网络无障碍?
使用prefers-reduced-motion理解用户偏好
减少 Svelte 过渡的运动
为无法访问的用户提供替代过渡
让我们从探讨什么是网络无障碍开始。
技术要求
本章中的所有代码都可以在以下链接找到:github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter15/01-accessible-transition
什么是网络无障碍?
无障碍是指产品、设备、服务或环境的设计,以便尽可能多的人可以使用,无论他们可能有什么物理、感官或认知障碍。
确保网站对所有用户都无障碍至关重要。有许多残疾可能会影响用户在网站上的体验。确保网站的无障碍性使得所有人,无论他们的能力如何,都能平等地访问到其他人可用的相同服务和信息。
许多可能妨碍网站用户体验的残疾中,前庭功能障碍是其中之一。在本章中,我们将特别关注提高患有前庭功能障碍的人士的无障碍性。
前庭功能障碍是影响内耳和大脑的疾病,它们可能导致平衡、空间定位和运动感知困难。想象一下,你身体的自然平衡感不正常。这就像头晕或感觉不稳。你脚下的地面感觉不稳定,你周围看到的东西似乎在移动,即使你站着不动。
对于患有前庭功能障碍的人来说,某些视觉刺激,如网页上移动或闪烁的内容,可能会引发头晕、恶心或偏头痛等症状。
我们在 第十三章 和 第十四章 中学习了如何添加过渡,以使我们的应用程序对用户更具吸引力。然而,对于有前庭功能障碍的用户,这些过渡可能会无意中提供负面的体验。大多数操作系统提供可访问性设置,使用户能够减少或删除动画。这些可访问性偏好可以被网络应用程序用来创建包容性的用户体验。
因此,让我们探索一个网络应用程序如何访问用户的可访问性偏好。
使用 prefers-reduced-motion 理解用户偏好
大多数操作系统都提供可访问性设置,允许用户禁用动画效果。例如,在 Windows 11 中,您可以导航到 设置 | 可访问性 | 视觉效果 | 动画效果,取消选中 动画效果 选项以关闭动画。
图 15.1:Windows 11 中的动画效果选项
在网络应用程序中,您可以使用 prefers-reduced-motion CSS 媒体查询来确定用户是否在他们的设备上激活了减少或消除非必要运动的设置。
以下是如何使用 prefers-reduced-motion CSS 媒体查询的示例:
@media (prefers-reduced-motion: reduce) {
div {
/* Removes animation */
animation: none;
}
}
在前面的代码片段中,如果用户表示偏好减少运动,我们将 CSS animation 属性设置为 none,以从 <div> 元素中移除动画。
或者,除了使用 CSS 之外,您还可以使用 JavaScript 来确定用户对减少运动的偏好。window.matchMedia 方法允许您检查网页是否匹配给定的媒体查询字符串。
在下面的代码片段中,我们使用 window.matchMedia 来测试 prefers-reduced-motion CSS 媒体查询是否匹配:
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const matches = mediaQuery.matches;
如果用户偏好减少运动,则前面代码片段中 matches 的值将为 true;否则,matches 的值将为 false。
用户在浏览网页时可能会更改他们的可访问性偏好。为了在用户更改对减少运动的偏好时得到通知,我们可以监听媒体查询的 change 事件。以下是方法:
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
mediaQuery.addEventListener('change', () => {
let matches = mediaQuery.matches;
});
在前面的代码片段中,每当用户更改他们对减少运动的偏好时,传递给 change 事件处理器的监听函数将被调用。它将通过 mediaQuery.matches 评估更新的用户偏好。
既然我们已经学会了如何通过 prefers-reduced-motion 确定用户对减少运动的偏好,让我们看看我们如何可以使用它来减少有前庭功能障碍用户的 Svelte 过渡。
减少 Svelte 过渡中的运动
在学习了如何获取用户对减少运动的偏好之后,现在让我们通过减少过渡中的不必要的动作来尊重这一偏好,这可能会引发前庭不适。
在下面的代码块中,有一个我们 Svelte 组件的示例,该组件将fly过渡应用于列表项:
<script>
import { fly } from 'svelte/transition';
export let list = [];
</script>
<ul>
{#each list as item}
<li transition:fly={{ x: 40 }}>{item}</li>
{/each}
</ul>
在前面的代码中,每当向列表中添加新项目时,一个新的<li>元素将从右侧飞入并插入到列表中。这种飞行运动可能成为前庭功能障碍用户的触发因素。
然而,飞行过渡并非必需,因为即使没有它,应用程序仍然可以正常工作。因此,如果用户在系统设置中表明了偏好减少运动,我们应该通过减少或移除飞行过渡来尊重这一偏好。
实现这一点的办法是将fly过渡的持续时间设置为0。这样,过渡将不会花费任何时间来播放和完成,实际上将不会播放。
这是之前 Svelte 组件的修改版本,如果用户偏好减少运动,则不会播放fly过渡:
<script>
import { fly } from 'svelte/transition';
export let list = [];
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let prefersReducedMotion = mediaQuery.matches;
mediaQuery.addEventListener('change', () => {
prefersReducedMotion = mediaQuery.matches;
});
</script>
<ul>
{#each list as item}
<li transition:fly={{
x: 40,
duration: prefersReducedMotion ? 0 : 400,
}}>{item}</li>
{/each}
</ul>
在前面的代码片段中,我们通过检查 CSS 媒体查询prefers-reduced-motion: reduce是否匹配来确定用户是否偏好减少运动,并将此信息存储在名为prefersReducedMotion的变量中。如果prefersReducedMotion为true(表示用户偏好减少运动),则我们将fly过渡的持续时间设置为0。当向列表中添加新项目时,用户将看不到任何飞行运动。
另一方面,如果用户没有前庭功能障碍,并且没有表达对减少运动效果的偏好,那么prefersReducedMotion将为false。在这种情况下,fly过渡的持续时间将被设置为400 ms,并且每当向列表中添加新项目时,都会显示飞行过渡。
然而,并非所有过渡都会触发前庭运动障碍。例如,fade过渡作为一种更微妙的动画,对前庭功能障碍用户的影响较小。我们不必通过将它们的持续时间设置为0来完全消除过渡,而是可以选择用更温和的过渡来替换更强烈的过渡。我们将在下一节中深入探讨这种方法。
为无法访问的用户提供替代过渡
前庭功能障碍用户在接触到基于运动的动画,如缩放或平移大对象时可能会感到不适。然而,他们通常受更微妙的动画,如淡入,的影响较小。
将所有过渡设置为淡入效果以适应前庭功能障碍用户并非万能的解决方案。最好总是寻求用户自身的反馈。
我们将继续使用上一节中的相同示例,并探讨当用户偏好减少运动时,如何从fly过渡切换到fade过渡。
需要注意的一点是,在 Svelte 中,不允许对一个元素应用超过一个过渡。
例如,以下代码是无效的,会导致构建错误:
<div transition:fade transition:fly />
这意味着我们不能将两个过渡应用于一个元素,然后决定使用哪一个。我们必须找到一种方法,在只对一个元素应用一个过渡的同时,在不同的过渡之间切换。
正如我们在第十四章中学习到的创建自定义过渡,Svelte 中的过渡是一个遵循过渡契约的函数。函数的返回值决定了过渡将如何执行。
因此,实现一个根据条件在两个过渡之间切换的过渡的一种方法,是创建一个自定义过渡,该过渡根据条件返回不同的过渡配置。
我们的自定义过渡可能看起来像以下代码,根据用户是否偏好减少运动返回不同的过渡配置:
<script>
function accessibleFly(node, params) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const matches = mediaQuery.matches;
if (matches) {
// user prefers reduced motion
// return a fade transition
} else {
// return a fly transition
}
}
</script>
<ul>
{#each list as item}
<li transition:accessibleFly={{ x: 40 }}>{item}</li>
{/each}
</ul>
在前面的代码片段中,我们定义了一个 accessibleFly 过渡,这是一个更易于访问的 fly 过渡,如果用户偏好减少运动,它将切换到 fade 过渡。
现在,我们需要确定在我们的自定义 accessibleFly 过渡中的每个条件情况下应该返回什么。
重要的是要记住,在 Svelte 中,过渡是一个 JavaScript 函数。因此,我们可以将 fly 和 fade 过渡都作为函数来调用,并且返回值将是每个相应过渡的过渡配置。通过这样做,我们可以从我们的 accessibleFly 过渡中返回这些值,从而有效地使我们的过渡可以是 fly 或 fade 过渡,具体取决于用户的偏好。
下面是更新后的 accessibleFly 过渡:
<script>
function accessibleFly(node, params) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const matches = mediaQuery.matches;
if (matches) {
// user prefers reduced motion
return fade(node, params);
} else {
return fly(node, params);
}
}
</script>
在前面的代码片段中,我们根据用户的偏好从 fade 或 fly 过渡中返回值。我们将传递给我们的 accessibleFly 过渡的 node 和 params 值传递给 fade 和 fly 过渡。node 和 params 值指定了过渡应用于哪个元素,并提供了用户参数,例如 duration 和 delay。这些对于 fade 和 fly 过渡来说很有用,可以确定过渡应该如何执行。
通过前面的代码更改,我们现在有一个名为 accessibleFly 的可访问 fly 过渡,默认情况下,它将使元素在过渡过程中飞出。然而,如果用户表示偏好减少运动,accessibleFly 过渡将使元素淡出。
由此,我们得到了一个既吸引大多数用户又考虑那些偏好减少运动的人的过渡。
您可以在github.com/PacktPublishing/Real-World-Svelte/tree/main/Chapter15/01-accessible-transition找到 accessibleFly 过渡的代码。
摘要
在本章中,我们探讨了无障碍访问在网页设计中的重要性以及如何实现考虑前庭功能障碍用户偏好的过渡效果。通过了解基于运动的动画对前庭功能障碍用户的影响,我们可以创建更包容和用户友好的网络应用程序。
我们学习了关于prefers-reduced-motion媒体查询的知识,它允许我们检测用户是否在其系统设置中表明了对减少运动的需求。使用这个媒体查询,我们可以调整我们的过渡效果以减少运动量,或者完全移除对偏好减少运动的用户。
我们还讨论了如何在 Svelte 中创建自定义过渡以实现无障碍访问。我们查看了一个名为accessibleFly的自定义过渡示例,它根据用户的减少运动偏好在fly和fade过渡之间切换。这个自定义过渡考虑到了患有前庭功能障碍的用户,同时为其他用户提供吸引人和有趣的过渡效果。
总结来说,无障碍访问在网页设计中至关重要,过渡效果也不例外。通过考虑所有用户的偏好和需求,包括前庭功能障碍用户,我们可以创建更包容和用户友好的网络应用程序。