将 Svelte 应用组件化
在上一篇文章中,我们开始开发待办事项应用程序。本文的主要目标是了解如何将应用程序分解成可管理的组件,以及如何在组件之间共享信息。我们将对应用程序进行组件化,然后添加更多功能,允许用户更新现有的组件。
先决条件 |
至少,建议您熟悉核心HTML、CSS和JavaScript语言,并了解终端/命令行。 您需要一个安装了 Node 和 npm 的终端来编译和构建您的应用程序。 |
---|---|
目标 | 学习如何将我们的应用程序分解成组件,以及如何在组件之间共享信息。 |
与我们一起编写代码
Git
使用以下命令克隆 GitHub 仓库(如果您尚未克隆):
git clone https://github.com/opensas/mdn-svelte-tutorial.git
然后,要进入当前应用程序状态,请运行:
cd mdn-svelte-tutorial/04-componentizing-our-app
或者直接下载文件夹的内容
npx degit opensas/mdn-svelte-tutorial/04-componentizing-our-app
请记住运行npm install && npm run dev
以开发模式启动应用程序。
REPL
要使用 REPL 与我们一起编写代码,请从以下地址开始:
https://svelte.net.cn/repl/99b9eb228b404a2f8c8959b22c0a40d3?version=3.23.2
将应用分解成组件
在 Svelte 中,应用程序由一个或多个组件组成。组件是一个可重用、自包含的代码块,封装了属于一起的 HTML、CSS 和 JavaScript,并写入 .svelte
文件中。组件可以很大或很小,但通常定义明确:最有效的组件服务于单一、明确的目的。
定义组件的好处类似于将代码组织成可管理片段的更通用的最佳实践。它将帮助您了解它们之间的关系,促进重用,并使您的代码更易于推理、维护和扩展。
但是,您如何知道哪些内容应该拆分成自己的组件呢?
对此没有硬性规定。有些人更喜欢直观的方法,开始查看标记并围绕每个似乎具有自身逻辑的组件和子组件绘制框。
其他人则应用与决定是否应创建新函数或对象相同的技术。其中一项技术是单一职责原则——即,组件理想情况下只应执行一项操作。如果它最终变得越来越大,则应将其拆分为更小的子组件。
这两种方法应该相互补充,帮助您决定如何更好地组织组件。
最终,我们将把我们的应用程序拆分为以下组件:
Alert.svelte
:用于传达已发生操作的通用通知框。NewTodo.svelte
:允许您输入新待办事项的文本输入框和按钮。FilterButton.svelte
:全部、活动和已完成按钮,允许您将过滤器应用于显示的待办事项。TodosStatus.svelte
:显示“x 个项目已完成,共 y 个项目”的标题。Todo.svelte
:单个待办事项。每个可见的待办事项都将在此组件的单独副本中显示。MoreActions.svelte
:UI 底部的选中全部和移除已完成按钮,允许您对待办事项执行批量操作。
在本文中,我们将专注于创建FilterButton
和Todo
组件;我们将在以后的文章中介绍其他组件。
让我们开始吧。
注意:在创建我们的前几个组件的过程中,我们还将学习在组件之间通信的不同技术以及每种技术的优缺点。
提取我们的筛选组件
我们将从创建FilterButton.svelte
开始。
- 首先,创建一个新文件,
components/FilterButton.svelte
。 - 在此文件中,我们将声明一个
filter
prop,然后将相关的标记从Todos.svelte
复制到其中。将以下内容添加到文件中:svelte<script> export let filter = 'all' </script> <div class="filters btn-group stack-exception"> <button class="btn toggle-btn" class:btn__primary={filter === 'all'} aria-pressed={filter === 'all'} on:click={() => filter = 'all'} > <span class="visually-hidden">Show</span> <span>All</span> <span class="visually-hidden">tasks</span> </button> <button class="btn toggle-btn" class:btn__primary={filter === 'active'} aria-pressed={filter === 'active'} on:click={() => filter = 'active'} > <span class="visually-hidden">Show</span> <span>Active</span> <span class="visually-hidden">tasks</span> </button> <button class="btn toggle-btn" class:btn__primary={filter === 'completed'} aria-pressed={filter === 'completed'} on:click={() => filter = 'completed'} > <span class="visually-hidden">Show</span> <span>Completed</span> <span class="visually-hidden">tasks</span> </button> </div>
- 回到我们的
Todos.svelte
组件中,我们希望使用FilterButton
组件。首先,我们需要导入它。在Todos.svelte
部分的顶部添加以下行:jsimport FilterButton from "./FilterButton.svelte";
- 现在用对
FilterButton
组件的调用替换svelte<FilterButton {filter} />
注意:请记住,当 HTML 属性名称和变量匹配时,它们可以用{variable}
替换。这就是为什么我们可以用
替换
。
到目前为止,一切都很好!现在让我们试用一下应用程序。您会注意到,当您点击过滤器按钮时,它们会被选中并且样式会相应更新。但是我们有一个问题:待办事项没有被过滤。这是因为filter
变量通过 prop 从Todos
组件向下传递到FilterButton
组件,但FilterButton
组件中发生的更改不会向上回传到其父组件——默认情况下数据绑定是单向的。让我们看看解决这个问题的方法。
组件之间共享数据:将处理程序作为 prop 传递
让子组件通知其父组件任何更改的一种方法是将处理程序作为 prop 传递。子组件将执行处理程序,并将所需的信息作为参数传递,处理程序将修改父组件的状态。
在我们的例子中,FilterButton
组件将从其父组件接收一个onclick
处理程序。每当用户点击任何过滤器按钮时,子组件都会调用onclick
处理程序,并将选定的过滤器作为参数回传到其父组件。
我们将只声明onclick
prop 并为其分配一个虚拟处理程序以防止错误,如下所示:
export let onclick = (clicked) => {};
并且我们将声明反应式语句$: onclick(filter)
,以便每当filter
变量更新时调用onclick
处理程序。
部分的
FilterButton
组件最终应如下所示。立即更新它:jsexport let filter = "all"; export let onclick = (clicked) => {}; $: onclick(filter);
- 现在,当我们在
Todos.svelte
中调用FilterButton
时,我们需要指定处理程序。像这样更新它:svelte<FilterButton {filter} onclick={ (clicked) => filter = clicked }/>
当任何过滤器按钮被点击时,我们只需用新的过滤器更新过滤器变量。现在我们的FilterButton
组件将再次工作。
使用 bind 指令更轻松地进行双向数据绑定
在前面的示例中,我们意识到我们的FilterButton
组件无法正常工作,因为我们的应用程序状态通过filter
prop 从父组件向下传递到子组件,但它没有向上回传。因此,我们添加了一个onclick
prop 以便子组件能够将其新的filter
值传达给其父组件。
它工作正常,但 Svelte 为我们提供了一种更简单、更直接的方法来实现双向数据绑定。数据通常使用 props 从父组件向下传递到子组件。如果我们希望它也以相反的方式流动,从子组件到父组件,我们可以使用bind:
指令。
使用bind
,我们将告诉 Svelte FilterButton
组件中对filter
prop 所做的任何更改都应传播回父组件Todos
。也就是说,我们将父组件中filter
变量的值绑定到子组件中的值。
- 在
Todos.svelte
中,按如下方式更新对FilterButton
组件的调用:像往常一样,Svelte 为我们提供了一个简洁的简写形式:svelte<FilterButton bind:filter={filter} />
bind:value={value}
等效于bind:value
。因此,在上面的示例中,您可以只写
。 - 子组件现在可以修改父组件的
filter
变量的值,因此我们不再需要onclick
prop。像这样修改FilterButton
的元素:
svelte<script> export let filter = "all"; </script>
- 再次尝试您的应用程序,您应该仍然看到过滤器正常工作。
创建我们的 Todo 组件
现在我们将创建一个Todo
组件来封装每个单独的待办事项,包括复选框和一些编辑逻辑,以便您可以更改现有的待办事项。
我们的Todo
组件将接收一个todo
对象作为 prop。让我们声明todo
prop 并从Todos
组件中移动代码。暂时,我们将用警报替换对removeTodo
的调用。我们稍后会添加回该功能。
- 创建一个新的组件文件,
components/Todo.svelte
。 - 将以下内容放入此文件中:svelte
<script> export let todo </script> <div class="stack-small"> <div class="c-cb"> <input type="checkbox" id="todo-{todo.id}" on:click={() => todo.completed = !todo.completed} checked={todo.completed} /> <label for="todo-{todo.id}" class="todo-label">{todo.name}</label> </div> <div class="btn-group"> <button type="button" class="btn"> Edit <span class="visually-hidden">{todo.name}</span> </button> <button type="button" class="btn btn__danger" on:click={() => alert('not implemented')}> Delete <span class="visually-hidden">{todo.name}</span> </button> </div> </div>
- 现在我们需要将我们的
Todo
组件导入到Todos.svelte
中。现在转到此文件,并在您之前的导入语句下方添加以下导入语句:jsimport Todo from "./Todo.svelte";
- 接下来,我们需要更新
{#each}
块,以便为每个待办事项包含一个
组件,而不是已移出到Todo.svelte
中的代码。我们还将当前的todo
对象作为 prop 传递到组件中。像这样更新Todos.svelte
中的{#each}
块:svelte<ul role="list" class="todo-list stack-large" aria-labelledby="list-heading"> {#each filterTodos(filter, todos) as todo (todo.id)} <li class="todo"> <Todo {todo} /> </li> {:else} <li>Nothing to do here!</li> {/each} </ul>
待办事项列表显示在页面上,复选框应该可以工作(尝试选中/取消选中几个,然后观察过滤器是否按预期工作),但我们的“x 个项目已完成,共 y 个项目”状态标题将不再相应更新。这是因为我们的Todo
组件通过 prop 接收待办事项,但它没有将任何信息发送回其父组件。我们稍后将解决这个问题。
组件之间共享数据:props-down,events-up 模式
bind
指令非常简单,允许您在父组件和子组件之间轻松共享数据。但是,当您的应用程序变得越来越大、越来越复杂时,跟踪所有绑定的值可能会变得很困难。“props-down, events-up”通信模式是一种不同的方法。
基本上,此模式依赖于子组件通过 props 从其父组件接收数据,以及父组件通过处理从子组件发出的事件来更新其状态。因此,props向下流动从父组件到子组件,事件向上冒泡从子组件到父组件。此模式建立了一个双向信息流,它具有可预测性和易于推理的特点。
让我们看看如何发出我们自己的事件以重新实现缺少的删除按钮功能。
要创建自定义事件,我们将使用createEventDispatcher
实用程序。这将返回一个dispatch()
函数,该函数将允许我们发出自定义事件。当您调度事件时,您必须传递事件的名称,以及可选地传递一个包含您想要传递给每个侦听器的其他信息的object。这些其他数据将在事件对象的detail
属性中可用。
注意:Svelte 中的自定义事件与常规 DOM 事件共享相同的 API。此外,您可以通过指定on:event
(不带任何处理程序)将事件向上冒泡到您的父组件。
我们将编辑我们的Todo
组件以发出remove
事件,并将要删除的待办事项作为其他信息传递。
- 首先,将以下几行添加到
Todo
组件的部分的顶部:
jsimport { createEventDispatcher } from "svelte"; const dispatch = createEventDispatcher();
- 现在更新同一文件中标记部分中的删除按钮,使其如下所示:使用svelte
<button type="button" class="btn btn__danger" on:click={() => dispatch('remove', todo)}> Delete <span class="visually-hidden">{todo.name}</span> </button>
dispatch('remove', todo)
,我们正在发出remove
事件,并将要删除的todo
作为其他数据传递。将调用处理程序,并提供一个可用的事件对象,其他数据在event.detail
属性中可用。 - 现在我们必须从
Todos.svelte
内部监听该事件并采取相应措施。返回此文件并像这样更新您的
组件调用:我们的处理程序接收svelte<Todo {todo} on:remove={(e) => removeTodo(e.detail)} />
e
参数(事件对象),如前所述,它在detail
属性中保存要删除的待办事项。 - 此时,如果您再次尝试您的应用程序,您应该会看到删除功能现在再次起作用了。因此,我们的自定义事件按我们希望的那样工作了。此外,
remove
事件侦听器正在将数据更改回传到父组件,因此当待办事项被删除时,我们的“x 个项目已完成,共 y 个项目”状态标题现在将相应更新。
现在我们将处理update
事件,以便我们的父组件可以收到任何待办事项修改的通知。
更新待办事项
我们还需要实现允许我们编辑现有待办事项的功能。我们必须在Todo
组件中包含一个编辑模式。进入编辑模式时,我们将显示一个<input>
字段,允许我们编辑当前待办事项的名称,并提供两个按钮来确认或取消我们的更改。
处理事件
- 我们需要一个变量来跟踪我们是否处于编辑模式,另一个变量来存储正在更新的任务的名称。在
Todo
组件的<script>
部分底部添加以下变量定义jslet editing = false; // track editing mode let name = todo.name; // hold the name of the to-do being edited
- 我们必须决定我们的
Todo
组件将发出哪些事件- 我们可以为状态切换和名称编辑发出不同的事件(例如,
updateTodoStatus
和updateTodoName
)。 - 或者我们可以采用更通用的方法,对这两个操作都发出一个
update
事件。
update()
函数,该函数将接收更改并使用修改后的待办事项发出更新事件。再次将以下内容添加到<script>
部分的底部在这里,我们使用扩展语法来返回应用了修改的原始待办事项。jsfunction update(updatedTodo) { todo = { ...todo, ...updatedTodo }; // applies modifications to todo dispatch("update", todo); // emit update event }
- 我们可以为状态切换和名称编辑发出不同的事件(例如,
- 接下来,我们将创建不同的函数来处理每个用户操作。当待办事项处于编辑模式时,用户可以保存或取消更改。当它不处于编辑模式时,用户可以删除待办事项、编辑它或在其已完成和活动状态之间切换。在您之前的函数下方添加以下函数集以处理这些操作js
function onCancel() { name = todo.name; // restores name to its initial value and editing = false; // and exit editing mode } function onSave() { update({ name }); // updates todo name editing = false; // and exit editing mode } function onRemove() { dispatch("remove", todo); // emit remove event } function onEdit() { editing = true; // enter editing mode } function onToggle() { update({ completed: !todo.completed }); // updates todo status }
更新标记
现在我们需要更新Todo
组件的标记,以便在采取适当操作时调用上述函数。
为了处理编辑模式,我们使用editing
变量,它是一个布尔值。当它为true
时,它应该显示用于编辑待办事项名称的<input>
字段,以及“取消”和“保存”按钮。当它不处于编辑模式时,它将显示复选框、待办事项名称以及编辑和删除待办事项的按钮。
为了实现这一点,我们将使用一个if
块。if
块有条件地呈现一些标记。请注意,它不会仅根据条件显示或隐藏标记——它将根据条件动态地向DOM添加和删除元素。
例如,当editing
为true
时,Svelte将显示更新表单;当它为false
时,它将从DOM中删除它并添加复选框。由于Svelte的响应性,分配editing
变量的值就足以显示正确的HTML元素。
以下内容使您了解基本的if
块结构
<div class="stack-small">
{#if editing}
<!-- markup for editing to-do: label, input text, Cancel and Save Button -->
{:else}
<!-- markup for displaying to-do: checkbox, label, Edit and Delete Button -->
{/if}
</div>
非编辑部分——即if
块的{:else}
部分(下半部分)——将与我们在Todos
组件中使用的部分非常相似。唯一的区别是我们根据用户操作调用onToggle()
、onEdit()
和onRemove()
。
{:else}
<div class="c-cb">
<input type="checkbox" id="todo-{todo.id}"
on:click={onToggle} checked={todo.completed}
>
<label for="todo-{todo.id}" class="todo-label">{todo.name}</label>
</div>
<div class="btn-group">
<button type="button" class="btn" on:click={onEdit}>
Edit<span class="visually-hidden"> {todo.name}</span>
</button>
<button type="button" class="btn btn__danger" on:click={onRemove}>
Delete<span class="visually-hidden"> {todo.name}</span>
</button>
</div>
{/if}
</div>
值得注意的是
- 当用户按下“编辑”按钮时,我们将执行
onEdit()
,它只是将editing
变量设置为true
。 - 当用户单击复选框时,我们将调用
onToggle()
函数,该函数执行update()
,并将一个包含新completed
值的对象作为参数传递。 update()
函数发出update
事件,并传递应用了更改的原始待办事项副本作为附加信息。- 最后,
onRemove()
函数发出remove
事件,并将要删除的todo
作为附加数据传递。
编辑UI(上半部分)将包含一个<input>
字段和两个按钮来取消或保存更改
<div class="stack-small">
{#if editing}
<form on:submit|preventDefault={onSave} class="stack-small" on:keydown={(e) => e.key === 'Escape' && onCancel()}>
<div class="form-group">
<label for="todo-{todo.id}" class="todo-label">New name for '{todo.name}'</label>
<input bind:value={name} type="text" id="todo-{todo.id}" autoComplete="off" class="todo-text" />
</div>
<div class="btn-group">
<button class="btn todo-cancel" on:click={onCancel} type="button">
Cancel<span class="visually-hidden">renaming {todo.name}</span>
</button>
<button class="btn btn__primary todo-edit" type="submit" disabled={!name}>
Save<span class="visually-hidden">new name for {todo.name}</span>
</button>
</div>
</form>
{:else}
[...]
当用户按下“编辑”按钮时,editing
变量将设置为true
,Svelte将删除DOM中{:else}
部分的标记,并将其替换为{#if}
部分中的标记。
<input>
的value
属性将绑定到name
变量,并且取消和保存更改的按钮分别调用onCancel()
和onSave()
(我们之前添加了这些函数)
- 当调用
onCancel()
时,name
将恢复到其原始值(作为prop传入),我们退出编辑模式(通过将editing
设置为false
)。 - 当调用
onSave()
时,我们将运行update()
函数——将修改后的name
传递给它——并退出编辑模式。
我们还在<input>
为空时禁用“保存”按钮,使用disabled={!name}
属性,并允许用户使用Escape键取消编辑,如下所示
on:keydown={(e) => e.key === 'Escape' && onCancel()}
我们还使用todo.id
为新的输入控件和标签创建唯一的ID。
- 我们
Todo
组件的完整更新标记如下所示。立即更新您的组件svelte<div class="stack-small"> {#if editing} <!-- markup for editing todo: label, input text, Cancel and Save Button --> <form on:submit|preventDefault={onSave} class="stack-small" on:keydown={(e) => e.key === 'Escape' && onCancel()}> <div class="form-group"> <label for="todo-{todo.id}" class="todo-label">New name for '{todo.name}'</label> <input bind:value={name} type="text" id="todo-{todo.id}" autoComplete="off" class="todo-text" /> </div> <div class="btn-group"> <button class="btn todo-cancel" on:click={onCancel} type="button"> Cancel<span class="visually-hidden">renaming {todo.name}</span> </button> <button class="btn btn__primary todo-edit" type="submit" disabled={!name}> Save<span class="visually-hidden">new name for {todo.name}</span> </button> </div> </form> {:else} <!-- markup for displaying todo: checkbox, label, Edit and Delete Button --> <div class="c-cb"> <input type="checkbox" id="todo-{todo.id}" on:click={onToggle} checked={todo.completed} > <label for="todo-{todo.id}" class="todo-label">{todo.name}</label> </div> <div class="btn-group"> <button type="button" class="btn" on:click={onEdit}> Edit<span class="visually-hidden"> {todo.name}</span> </button> <button type="button" class="btn btn__danger" on:click={onRemove}> Delete<span class="visually-hidden"> {todo.name}</span> </button> </div> {/if} </div>
注意:我们可以进一步将其拆分为两个不同的组件,一个用于编辑待办事项,另一个用于显示它。最终,这取决于您在单个组件中处理这种复杂程度的舒适度。您还应该考虑是否进一步拆分它将能够在不同的上下文中重用此组件。
- 为了使更新功能正常工作,我们必须处理来自
Todos
组件的update
事件。在其<script>
部分中,添加此处理程序我们通过jsfunction updateTodo(todo) { const i = todos.findIndex((t) => t.id === todo.id); todos[i] = { ...todos[i], ...todo }; }
id
在todos
数组中找到todo
,并使用扩展语法更新其内容。在这种情况下,我们也可以只使用todos[i] = todo
,但此实现更可靠,允许Todo
组件仅返回待办事项的更新部分。 - 接下来,我们必须监听
<Todo>
组件调用的update
事件,并在发生这种情况时运行我们的updateTodo()
函数以更改name
和completed
状态。像这样更新您的<Todo>调用svelte{#each filterTodos(filter, todos) as todo (todo.id)} <li class="todo"> <Todo {todo} on:update={(e) => updateTodo(e.detail)} on:remove={(e) => removeTodo(e.detail)} /> </li>
- 再次尝试您的应用程序,您应该会看到您可以删除、添加、编辑、取消编辑和切换待办事项的完成状态。并且我们的“x/y项已完成”状态标题现在将在待办事项完成时适当地更新。
如您所见,在Svelte中很容易实现“prop向下,事件向上”模式。但是,对于简单的组件,bind
可能是一个不错的选择;Svelte将让您选择。
注意:Svelte提供了更高级的机制来在组件之间共享信息:Context API和Stores。Context API提供了一种机制,允许组件及其后代相互“通信”,而无需将数据和函数作为prop传递,或分派大量事件。Stores允许您在与层次结构无关的组件之间共享响应式数据。我们将在本系列的后面部分介绍Stores。
目前的代码
Git
要查看本文结束时代码的状态,请像这样访问您存储库的副本
cd mdn-svelte-tutorial/05-advanced-concepts
或者直接下载文件夹的内容
npx degit opensas/mdn-svelte-tutorial/05-advanced-concepts
请记住运行npm install && npm run dev
以开发模式启动应用程序。
REPL
要在REPL中查看代码的当前状态,请访问
https://svelte.net.cn/repl/76cc90c43a37452e8c7f70521f88b698?version=3.23.2