将 Svelte 应用组件化

在上一篇文章中,我们开始开发待办事项应用。本文的核心目标是探讨如何将应用分解为可管理的组件,并在它们之间共享信息。我们将把应用组件化,然后添加更多功能,允许用户更新现有组件。

预备知识

至少,建议您熟悉核心 HTMLCSSJavaScript 语言,并了解 终端/命令行

您需要安装了 Node 和 npm 的终端才能编译和构建您的应用程序。

目标 学习如何将我们的应用程序分解为组件并在它们之间共享信息。

与我们一起编写代码

Git

使用以下命令克隆 GitHub 仓库(如果您尚未这样做)

bash
git clone https://github.com/opensas/mdn-svelte-tutorial.git

然后运行以下命令以获取当前应用程序状态

bash
cd mdn-svelte-tutorial/04-componentizing-our-app

或者直接下载文件夹内容

bash
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 底部的“全选”和“删除已完成”按钮,允许您对待办事项执行批量操作。

graphical representation of the list of components in our app

在本文中,我们将专注于创建 FilterButtonTodo 组件;我们将在未来的文章中介绍其他组件。

让我们开始吧。

注意:在创建前几个组件的过程中,我们还将学习组件之间通信的不同技术,以及每种技术的优缺点。

提取我们的筛选器组件

我们将从创建 FilterButton.svelte 开始。

  1. 首先,创建一个新文件 components/FilterButton.svelte

  2. 在这个文件中,我们将声明一个 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>
    
  3. 回到我们的 Todos.svelte 组件中,我们希望使用我们的 FilterButton 组件。首先,我们需要导入它。在 Todos.svelte <script> 部分的顶部添加以下行

    js
    import FilterButton from "./FilterButton.svelte";
    
  4. 现在将 <div class="filters... 元素替换为对 FilterButton 组件的调用,该组件将当前筛选器作为 prop。下面的行就是您所需要的一切

    svelte
    <FilterButton {filter} />
    

注意:请记住,当 HTML 属性名称和变量匹配时,它们可以用 {variable} 替换。这就是为什么我们可以将 <FilterButton filter={filter} /> 替换为 <FilterButton {filter} />

到目前为止一切顺利!现在让我们尝试一下这个应用。您会注意到,当您单击筛选按钮时,它们会被选中,并且样式会相应更新。但我们有一个问题:待办事项未被筛选。这是因为 filter 变量通过 prop 从 Todos 组件流向 FilterButton 组件,但 FilterButton 组件中发生的变化不会流回其父级——数据绑定默认是单向的。让我们看看解决这个问题的方法。

组件之间共享数据:将处理程序作为 prop 传递

让子组件通知其父组件任何更改的一种方法是将处理程序作为 prop 传递。子组件将执行处理程序,将所需信息作为参数传递,处理程序将修改父组件的状态。

在我们的案例中,FilterButton 组件将从其父组件接收一个 onclick 处理程序。每当用户单击任何筛选按钮时,子组件将调用 onclick 处理程序,将选定的筛选器作为参数传回其父组件。

我们将只声明 onclick prop,并分配一个虚拟处理程序以防止错误,如下所示

js
export let onclick = (clicked) => {};

我们将声明响应式语句 $: onclick(filter),以便在 filter 变量更新时调用 onclick 处理程序。

  1. FilterButton 组件的 <script> 部分最终应如下所示。立即更新它

    js
    export let filter = "all";
    export let onclick = (clicked) => {};
    $: onclick(filter);
    
  2. 现在,当我们在 Todos.svelte 内部调用 FilterButton 时,我们需要指定处理程序。像这样更新它

    svelte
    <FilterButton {filter} onclick={ (clicked) => filter = clicked }/>
    

当任何筛选按钮被点击时,我们只需用新的筛选器更新筛选器变量。现在我们的 FilterButton 组件将再次工作。

使用 bind 指令更轻松地实现双向数据绑定

在前面的示例中,我们发现我们的 FilterButton 组件无法工作,因为我们的应用程序状态通过 filter prop 从父级流向子级,但它没有流回。因此,我们添加了一个 onclick prop,让子组件将新的 filter 值传达给其父级。

它工作正常,但 Svelte 为我们提供了一种更简单、更直接的方式来实现双向数据绑定。数据通常使用 prop 从父级流向子级。如果我们也希望它从子级流向父级,我们可以使用 bind: 指令

使用 bind,我们将告诉 Svelte,在 FilterButton 组件中对 filter prop 所做的任何更改都应传播回父组件 Todos。也就是说,我们将父组件中 filter 变量的值绑定到其子组件中的值。

  1. Todos.svelte 中,如下更新对 FilterButton 组件的调用

    svelte
    <FilterButton bind:filter={filter} />
    

    像往常一样,Svelte 为我们提供了一个很好的简写:bind:value={value} 等效于 bind:value。因此,在上面的示例中,您只需编写 <FilterButton bind:filter />

  2. 子组件现在可以修改父组件的 filter 变量的值,因此我们不再需要 onclick prop。像这样修改 FilterButton<script> 元素

    svelte
    <script>
      export let filter = "all";
    </script>
    
  3. 再次尝试您的应用,您应该仍然看到您的筛选器正常工作。

创建我们的 Todo 组件

现在我们将创建一个 Todo 组件来封装每个单独的待办事项,包括复选框和一些编辑逻辑,以便您可以更改现有待办事项。

我们的 Todo 组件将接收一个 todo 对象作为 prop。让我们声明 todo prop 并将代码从 Todos 组件中移出。暂时,我们将把对 removeTodo 的调用替换为警报。我们稍后会添加该功能。

  1. 创建一个新的组件文件 components/Todo.svelte

  2. 将以下内容放入此文件中

    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>
    
  3. 现在我们需要将 Todo 组件导入到 Todos.svelte 中。现在转到此文件,并在您之前的导入语句下方添加以下 import 语句

    js
    import Todo from "./Todo.svelte";
    
  4. 接下来,我们需要更新我们的 {#each} 块,为每个待办事项包含一个 <Todo> 组件,而不是已移出到 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() 函数,该函数将允许我们发出自定义事件。当您分派事件时,您必须传递事件的名称,并且可选地传递一个包含您想要传递给每个监听器的附加信息的对象。此附加数据将在事件对象的 detail 属性上可用。

注意:Svelte 中的自定义事件与常规 DOM 事件共享相同的 API。此外,您可以通过指定 on:event 而不带任何处理程序来将事件冒泡到父组件。

我们将编辑我们的 Todo 组件以发出一个 remove 事件,将要删除的待办事项作为附加信息传递。

  1. 首先,将以下行添加到 Todo 组件的 <script> 部分的顶部

    js
    import { createEventDispatcher } from "svelte";
    
    const dispatch = createEventDispatcher();
    
  2. 现在,更新同一文件标记部分中的“删除”按钮,使其看起来像这样

    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 属性中获取。

  3. 现在我们必须在 Todos.svelte 内部监听该事件并采取相应行动。返回到该文件,并像这样更新您的 <Todo> 组件调用

    svelte
    <Todo {todo} on:remove={(e) => removeTodo(e.detail)} />
    

    我们的处理程序接收 e 参数(事件对象),如前所述,它在 detail 属性中保存了要删除的待办事项。

  4. 此时,如果您再次尝试您的应用程序,您应该会看到“删除”功能现在又可以工作了。所以我们的自定义事件按我们希望的方式工作了。此外,remove 事件监听器正在将数据更改发送回父级,因此当待办事项被删除时,我们的“x 个项目中 y 个已完成”的状态标题现在将相应更新。

现在我们将处理 update 事件,以便我们的父组件可以收到任何修改过的待办事项的通知。

更新待办事项

我们仍需实现允许我们编辑现有待办事项的功能。我们必须在 Todo 组件中包含一个编辑模式。进入编辑模式时,我们将显示一个 <input> 字段,允许我们编辑当前待办事项名称,并带有两个按钮来确认或取消我们的更改。

处理事件

  1. 我们需要一个变量来跟踪我们是否处于编辑模式,另一个变量来存储正在更新的任务名称。在 Todo 组件的 <script> 部分底部添加以下变量定义

    js
    let editing = false; // track editing mode
    let name = todo.name; // hold the name of the to-do being edited
    
  2. 我们必须决定我们的 Todo 组件将发出哪些事件

    • 我们可以为状态切换和名称编辑发出不同的事件(例如,updateTodoStatusupdateTodoName)。
    • 或者我们可以采取更通用的方法,为这两个操作发出一个单独的 update 事件。

    我们将采取第二种方法,以便我们能够演示一种不同的技术。这种方法的优点是,稍后我们可以为待办事项添加更多字段,并且仍然可以使用相同的事件处理所有更新。

    让我们创建一个 update() 函数,它将接收更改并发出一个带有修改后的待办事项的更新事件。在 <script> 部分的底部再次添加以下内容

    js
    function update(updatedTodo) {
      todo = { ...todo, ...updatedTodo }; // applies modifications to todo
      dispatch("update", todo); // emit update event
    }
    

    这里我们使用展开语法返回已应用修改的原始待办事项。

  3. 接下来,我们将创建不同的函数来处理每个用户操作。当待办事项处于编辑模式时,用户可以保存或取消更改。当它不处于编辑模式时,用户可以删除待办事项、编辑它或切换其状态(在已完成和活动之间)。

    在您之前的函数下方添加以下一组函数来处理这些操作

    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> 字段,以及“取消”和“保存”按钮。当它不处于编辑模式时,它将显示复选框、待办事项名称以及编辑和删除待办事项的按钮。

为了实现这一点,我们将使用一个 ifif 块有条件地渲染一些标记。请注意,它不仅仅是根据条件显示或隐藏标记——它会根据条件动态地从 DOM 中添加和删除元素。

例如,当 editingtrue 时,Svelte 将显示更新表单;当它为 false 时,它将从 DOM 中删除它并添加复选框。由于 Svelte 的响应性,分配编辑变量的值就足以显示正确的 HTML 元素。

以下内容让您了解基本的 if 块结构是什么样的

svelte
<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()

svelte
{: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> 字段和两个按钮,用于取消或保存更改

svelte
<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。

  1. 我们 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>
    

    注意:我们可以进一步将其拆分为两个不同的组件,一个用于编辑待办事项,另一个用于显示待办事项。最终,这取决于您在一个组件中处理此级别复杂度的舒适程度。您还应该考虑进一步拆分是否能够在不同的上下文中重用此组件。

  2. 为了使更新功能正常工作,我们必须处理 Todos 组件中的 update 事件。在其 <script> 部分中,添加此处理程序

    js
    function updateTodo(todo) {
      const i = todos.findIndex((t) => t.id === todo.id);
      todos[i] = { ...todos[i], ...todo };
    }
    

    我们在 todos 数组中通过 id 找到 todo,并使用扩展语法更新其内容。在这种情况下,我们也可以只使用 todos[i] = todo,但这种实现更稳健,允许 Todo 组件只返回待办事项的更新部分。

  3. 接下来,我们必须在我们的 <Todo> 组件调用上监听 update 事件,并在发生时运行我们的 updateTodo() 函数以更改 namecompleted 状态。像这样更新您的 <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>
    
  4. 再次尝试您的应用,您应该会看到您可以删除、添加、编辑、取消编辑以及切换待办事项的完成状态。而且,当待办事项完成时,我们的“x 条待办事项中有 y 条已完成”的状态标题现在将相应更新。

如您所见,在 Svelte 中实现“props-down, events-up”模式很容易。然而,对于简单的组件,bind 可能是一个不错的选择;Svelte 会让您选择。

注意: Svelte 提供了更高级的机制来在组件之间共享信息:Context APIStores。Context API 提供了一种机制,使组件及其后代能够相互“交谈”,而无需通过 props 传递数据和函数,或分派大量事件。Stores 允许您在没有层级关系的组件之间共享响应式数据。我们将在本系列的后续文章中介绍 Stores。

目前的全部代码

Git

要查看本文末尾代码的状态,请像这样访问您仓库的副本

bash
cd mdn-svelte-tutorial/05-advanced-concepts

或者直接下载文件夹内容

bash
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

总结

现在我们已经具备了应用程序所有必需的功能。我们可以显示、添加、编辑和删除待办事项,将它们标记为已完成,并按状态进行筛选。

在本文中,我们涵盖了以下主题

  • 将功能提取到新组件
  • 使用作为 prop 接收的处理程序从子组件向父组件传递信息
  • 使用 bind 指令从子组件向父组件传递信息
  • 使用 if 块有条件地渲染标记块
  • 实现“props-down, events-up”通信模式
  • 创建和监听自定义事件

在下一篇文章中,我们将继续将我们的应用组件化,并研究一些处理 DOM 的高级技术。