Svelte 中的动态行为:使用变量和 props

现在我们的标记和样式已准备就绪,我们可以开始为 Svelte 待办事项列表应用程序开发所需的功能。在本文中,我们将使用变量和属性来使我们的应用程序动态化,允许我们添加和删除待办事项,将它们标记为已完成,并按状态对其进行过滤。

先决条件

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

您需要一个安装了 node 和 npm 的终端来编译和构建您的应用程序。

目标 学习并实践一些基本的 Svelte 概念,例如创建组件、使用属性传递数据、将 JavaScript 表达式渲染到我们的标记中、修改组件的状态以及遍历列表。

与我们一起编写代码

Git

克隆 GitHub 仓库(如果您还没有这样做)

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

然后,要进入当前应用程序状态,请运行

bash
cd mdn-svelte-tutorial/03-adding-dynamic-behavior

或直接下载文件夹的内容

bash
npx degit opensas/mdn-svelte-tutorial/03-adding-dynamic-behavior

请记住运行 npm install && npm run dev 以在开发模式下启动您的应用程序。

REPL

要使用 REPL 与我们一起编写代码,请从以下地址开始

https://svelte.net.cn/repl/c862d964d48d473ca63ab91709a0a5a0?version=3.23.2

使用待办事项

我们的 Todos.svelte 组件目前仅显示静态标记;让我们开始使其更具动态性。我们将从标记中获取任务信息并将其存储在 todos 数组中。我们还将创建两个变量来跟踪任务总数和已完成的任务。

组件的状态将由这三个顶级变量表示。

  1. src/components/Todos.svelte 的顶部创建一个 <script> 部分,并为其提供一些内容,如下所示
    svelte
    <script>
      let todos = [
        { id: 1, name: "Create a Svelte starter app", completed: true },
        { id: 2, name: "Create your first component", completed: true },
        { id: 3, name: "Complete the rest of the tutorial", completed: false }
      ];
      let totalTodos = todos.length;
      let completedTodos = todos.filter((todo) => todo.completed).length;
    </script>
    
    现在让我们对这些信息做点什么。
  2. 让我们从显示状态消息开始。找到 idlist-heading<h2> 标题,并用动态表达式替换硬编码的活动任务和已完成任务的数量
    svelte
    <h2 id="list-heading">{completedTodos} out of {totalTodos} items completed</h2>
    
  3. 转到应用程序,您应该会看到之前显示的“已完成 3 个项目中的 2 个”消息,但这次信息来自 todos 数组。
  4. 为了证明这一点,请转到该数组,尝试更改某些待办事项对象的 completed 属性值,甚至添加新的待办事项对象。观察消息中的数字如何被相应地更新。

从数据动态生成待办事项

目前,我们显示的待办事项都是静态的。我们希望遍历 todos 数组中的每个项目并为每个任务渲染标记,所以现在让我们这样做。

HTML 无法表达逻辑——例如条件和循环。Svelte 可以。在这种情况下,我们使用 {#each} 指令来迭代 todos 数组。如果提供,第二个参数将包含当前项目的索引。此外,可以提供一个键表达式,该表达式将唯一标识每个项目。Svelte 将在数据更改时使用它来比较列表,而不是在末尾添加或删除项目,并且始终指定一个键表达式是一个好习惯。最后,可以提供一个 :else 块,该块将在列表为空时呈现。

让我们试一试。

  1. 将现有的 <ul> 元素替换为以下简化版本,以了解其工作原理
    svelte
    <ul>
    {#each todos as todo, index (todo.id)}
      <li>
        <input type="checkbox" checked={todo.completed}/> {index}. {todo.name} (id: {todo.id})
      </li>
    {:else}
      Nothing to do here!
    {/each}
    </ul>
    
  2. 返回应用程序;您将看到如下内容:使用 each 块创建的非常简单的待办事项列表输出
  3. 现在我们已经看到它可以工作了,让我们为 {#each} 指令的每个循环生成一个完整的待办事项,并在其中嵌入来自 todos 数组的信息:idnamecompleted。将您现有的 <ul> 块替换为以下内容
    svelte
    <!-- To-dos -->
    <ul role="list" class="todo-list stack-large" aria-labelledby="list-heading">
      {#each todos as todo (todo.id)}
      <li class="todo">
        <div class="stack-small">
          <div class="c-cb">
            <input
              type="checkbox"
              id="todo-{todo.id}"
              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">
              Delete <span class="visually-hidden">{todo.name}</span>
            </button>
          </div>
        </div>
      </li>
      {:else}
      <li>Nothing to do here!</li>
      {/each}
    </ul>
    
    请注意,我们如何使用花括号将 JavaScript 表达式嵌入 HTML 属性中,就像我们在复选框的 checkedid 属性中所做的那样。

我们已将静态标记转换为动态模板,准备显示组件状态中的任务。太棒了!我们正在接近目标。

使用 props

使用硬编码的待办事项列表,我们的 Todos 组件并不是很有用。为了将我们的组件转变为通用的待办事项编辑器,我们应该允许该组件的父组件传入要编辑的待办事项列表。这将允许我们将它们保存到 Web 服务或本地存储中,并在以后检索它们以进行更新。所以让我们将数组转换为 prop

  1. Todos.svelte 中,用 export let todos = [] 替换现有的 let todos = … 块。
    js
    export let todos = [];
    
    这起初可能感觉有点奇怪。这不是 JavaScript 模块中 export 的正常工作方式!这是 Svelte 如何通过采用有效语法并赋予其新的用途来“扩展”JavaScript 的。在这种情况下,Svelte 使用 export 关键字将变量声明标记为属性或 prop,这意味着它对组件的使用者变得可用。您还可以为 prop 指定默认初始值。如果组件的使用者在实例化组件时没有在组件上指定 prop——或者如果其初始值为未定义——则将使用此值。因此,使用 export let todos = [],我们告诉 Svelte 我们的 Todos.svelte 组件将接受一个 todos 属性,如果省略该属性,则将初始化为空数组。
  2. 看看应用程序,您将看到“此处无事可做!”消息。这是因为我们目前没有从 App.svelte 中向其传递任何值,因此它正在使用默认值。
  3. 现在让我们将待办事项移至 App.svelte 并将它们作为 prop 传递给 Todos.svelte 组件。更新 src/App.svelte 如下所示
    svelte
    <script>
      import Todos from "./components/Todos.svelte";
    
      let todos = [
        { id: 1, name: "Create a Svelte starter app", completed: true },
        { id: 2, name: "Create your first component", completed: true },
        { id: 3, name: "Complete the rest of the tutorial", completed: false }
      ];
    </script>
    
    <Todos todos={todos} />
    
  4. 当属性和变量具有相同的名称时,Svelte 允许您仅指定变量作为便捷快捷方式,因此我们可以将最后一行重写为如下所示。现在试试看。
    svelte
    <Todos {todos} />
    

此时,您的待办事项应该像以前一样呈现,只是现在我们是从 App.svelte 组件中传入的。

切换和删除待办事项

让我们添加一些功能来切换任务状态。Svelte 具有 on:eventname 指令用于监听 DOM 事件。让我们为复选框输入的 on:click 事件添加一个处理程序以切换 completed 值。

  1. 更新 src/components/Todos.svelte 中的 <input type="checkbox"> 元素,如下所示
    svelte
    <input type="checkbox" id="todo-{todo.id}"
      on:click={() => todo.completed = !todo.completed}
      checked={todo.completed}
    />
    
  2. 接下来,我们将添加一个函数以从我们的 todos 数组中删除一个待办事项。在 Todos.svelte<script> 部分底部,添加 removeTodo() 函数,如下所示
    js
    function removeTodo(todo) {
      todos = todos.filter((t) => t.id !== todo.id);
    }
    
  3. 我们将通过“删除”按钮调用它。使用 click 事件更新它,如下所示
    svelte
    <button type="button" class="btn btn__danger"
      on:click={() => removeTodo(todo)}
    >
      Delete <span class="visually-hidden">{todo.name}</span>
    </button>
    
    Svelte 中处理程序的一个非常常见的错误是将函数执行的结果作为处理程序传递,而不是传递函数本身。例如,如果您指定 on:click={removeTodo(todo)},它将执行 removeTodo(todo),并且结果将作为处理程序传递,这不是我们想要的。在这种情况下,您必须将 on:click={() => removeTodo(todo)} 指定为处理程序。如果 removeTodo() 没有接收参数,您可以使用 on:event={removeTodo},但不能使用 on:event={removeTodo()}。这不是某些特殊的 Svelte 语法——在这里我们只是使用常规的 JavaScript 箭头函数

同样,这是一个很好的进展——此时,我们现在可以删除任务了。当按下待办事项的“删除”按钮时,相关待办事项将从 todos 数组中删除,并且 UI 将更新为不再显示它。此外,我们现在可以选中复选框,并且相关待办事项的已完成状态现在将在 todos 数组中更新。

但是,“x 个项目中的 y 个已完成”标题没有更新。继续阅读以了解为什么会发生这种情况以及我们如何解决它。

反应式待办事项

正如我们已经看到的,每次修改组件顶级变量的值时,Svelte 都知道如何更新 UI。在我们的应用程序中,每次切换或删除待办事项时,都会直接更新 todos 数组的值,因此 Svelte 将自动更新 DOM。

但是,totalTodoscompletedTodos 并非如此。在以下代码中,在实例化组件并执行脚本时,会为它们分配一个值,但在此之后,不会修改它们的值

js
let totalTodos = todos.length;
let completedTodos = todos.filter((todo) => todo.completed).length;

我们可以在切换和删除待办事项后重新计算它们,但有一种更简单的方法。

我们可以告诉 Svelte 我们希望我们的 totalTodoscompletedTodos 变量具有反应性,方法是在它们前面加上 $:。每当它们依赖的数据发生更改时,Svelte 将生成代码以自动更新它们。

注意:Svelte 使用 $: JavaScript 标签语句语法 来标记反应性语句。就像使用 export 关键字声明 prop 一样,这可能看起来有点陌生。这是另一个例子,其中 Svelte 利用有效的 JavaScript 语法并赋予其新的用途——在这种情况下,表示“每当任何引用的值更改时重新运行此代码”。一旦你习惯了它,就再也回不去了。

更新 src/components/Todos.svelte 中的 totalTodoscompletedTodos 变量定义,使其如下所示

js
$: totalTodos = todos.length;
$: completedTodos = todos.filter((todo) => todo.completed).length;

如果您现在检查您的应用程序,您将看到标题的数字在待办事项完成或删除时会更新。不错!

在幕后,Svelte 编译器将解析和分析我们的代码以创建依赖项树,然后它将生成 JavaScript 代码以在其中一个依赖项更新时重新评估每个反应性语句。Svelte 中的反应性以非常轻量级且高效的方式实现,无需使用侦听器、设置器、获取器或任何其他复杂机制。

添加新的待办事项

现在转到本文的下一个主要任务——让我们添加一些添加新待办事项的功能。

  1. 首先,我们将创建一个变量来保存新待办事项的文本。将此声明添加到 Todos.svelte 文件的 <script> 部分
    js
    let newTodoName = "";
    
  2. 现在,我们将在添加新任务的 <input> 中使用此值。为此,我们需要将我们的 newTodoName 变量绑定到 todo-0 输入,以便 newTodoName 变量值与输入的 value 属性保持同步。我们可以这样做
    svelte
    <input value={newTodoName} on:keydown={(e) => newTodoName = e.target.value} />
    
    每当 newTodoName 变量的值发生变化时,它都会反映在输入的 value 属性中,并且每当在输入中按下键时,我们都会更新 newTodoName 变量的内容。这是输入框双向数据绑定的手动实现。但我们不需要这样做——Svelte 提供了一种更简单的方法来将任何属性绑定到变量,使用 bind:property 指令
    svelte
    <input bind:value={newTodoName} />
    
    因此,让我们实现这一点。更新 todo-0 输入,如下所示
    svelte
    <input
      bind:value={newTodoName}
      type="text"
      id="todo-0"
      autocomplete="off"
      class="input input__lg" />
    
  3. 测试此功能是否有效的一种简单方法是添加一个反应性语句来记录 newTodoName 的内容。在 <script> 部分末尾添加此代码段
    js
    $: console.log("newTodoName: ", newTodoName);
    

    注意:您可能已经注意到,反应性语句不限于变量声明。您可以在 $: 符号后放置任何 JavaScript 语句。

  4. 现在尝试返回 localhost:5042,按下 Ctrl + Shift + K 打开浏览器控制台,并在输入字段中输入一些内容。你应该会看到你的输入被记录下来。此时,如果愿意,你可以删除响应式的 console.log()
  5. 接下来,我们将创建一个函数来添加新的待办事项——addTodo()——它会将一个新的 todo 对象推送到 todos 数组中。将此添加到 src/components/Todos.svelte<script> 块的底部。
    js
    function addTodo() {
      todos.push({ id: 999, name: newTodoName, completed: false });
      newTodoName = "";
    }
    

    注意:目前我们只是为每个待办事项分配相同的 id,但不用担心,我们很快就会解决这个问题。

  6. 现在我们希望更新我们的 HTML,以便在表单提交时调用 addTodo()。像这样更新 NewTodo 表单的起始标签
    svelte
    <form on:submit|preventDefault={addTodo}>
    
    on:eventname 指令支持使用 | 字符向 DOM 事件添加修饰符。在本例中,preventDefault 修饰符告诉 Svelte 生成调用 event.preventDefault() 的代码,然后再运行处理程序。浏览之前的链接以查看可用的其他修饰符。
  7. 如果你现在尝试添加新的待办事项,新的待办事项会添加到待办事项数组中,但我们的 UI 不会更新。请记住,在 Svelte 中,响应性是由赋值触发的。这意味着 addTodo() 函数被执行,元素被添加到 todos 数组中,但 Svelte 不会检测到 push 方法修改了数组,因此它不会刷新任务 <ul>。只需在 addTodo() 函数的末尾添加 todos = todos 就可以解决问题,但这看起来很奇怪,因为必须在函数的末尾包含它。相反,我们将取出 push() 方法,并使用展开语法来实现相同的结果:我们将为 todos 数组分配一个值,该值等于 todos 数组加上新对象。

    注意:Array 有几个可变操作:push()pop()splice()shift()unshift()reverse()sort()。使用它们通常会导致难以跟踪的副作用和错误。通过使用展开语法而不是 push(),我们避免了修改数组,这被认为是一种良好的实践。

    像这样更新你的 addTodo() 函数
    js
    function addTodo() {
      todos = [...todos, { id: 999, name: newTodoName, completed: false }];
      newTodoName = "";
    }
    

为每个待办事项提供唯一的 ID

如果你现在尝试在你的应用中添加新的待办事项,你将能够添加一个新的待办事项并使其出现在 UI 中——一次。如果你第二次尝试,它将无法工作,并且你会收到一条控制台消息,提示“错误:带键的 each 中不能有重复的键”。我们需要为我们的待办事项提供唯一的 ID。

  1. 让我们声明一个 newTodoId 变量,该变量根据待办事项的数量加 1 计算得出,并使其具有响应性。将以下代码片段添加到 <script> 部分
    js
    let newTodoId;
    $: {
      if (totalTodos === 0) {
        newTodoId = 1;
      } else {
        newTodoId = Math.max(...todos.map((t) => t.id)) + 1;
      }
    }
    

    注意:如你所见,响应式语句不限于单行语句。以下方法也可以使用,但可读性稍差:$: newTodoId = totalTodos ? Math.max(...todos.map((t) => t.id)) + 1 : 1

  2. Svelte 是如何实现这一点的?编译器解析整个响应式语句,并检测到它依赖于 totalTodos 变量和 todos 数组。因此,每当其中任何一个被修改时,此代码就会重新计算,相应地更新 newTodoId。让我们在 addTodo() 函数中使用它。像这样更新它
    js
    function addTodo() {
      todos = [...todos, { id: newTodoId, name: newTodoName, completed: false }];
      newTodoName = "";
    }
    

按状态筛选待办事项

最后,对于本文,让我们实现按状态过滤待办事项的功能。我们将创建一个变量来保存当前过滤器,以及一个将返回过滤后的待办事项的辅助函数。

  1. 在我们的 <script> 部分底部添加以下内容
    js
    let filter = "all";
    const filterTodos = (filter, todos) =>
      filter === "active"
        ? todos.filter((t) => !t.completed)
        : filter === "completed"
          ? todos.filter((t) => t.completed)
          : todos;
    
    我们使用 filter 变量来控制活动过滤器:allactivecompleted。只需将这些值之一分配给 filter 变量,就会激活过滤器并更新待办事项列表。让我们看看如何实现这一点。filterTodos() 函数将接收当前过滤器和待办事项列表,并返回一个相应过滤后的新待办事项数组。
  2. 让我们更新过滤器按钮标记,使其成为动态的,并在用户按下其中一个过滤器按钮时更新当前过滤器。像这样更新它
    svelte
    <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>
    
    此标记中发生了一些事情。我们将通过将 btn__primary 类应用于活动过滤器按钮来显示当前过滤器。为了有条件地将样式类应用于元素,我们使用 class:name={value} 指令。如果值表达式计算结果为真值,则会应用类名。你可以在同一个元素上添加许多此类指令,并使用不同的条件。因此,当我们发出 class:btn__primary={filter === 'all'} 时,如果 filter 等于 all,则 Svelte 将应用 btn__primary 类。

    注意:当类与变量名称匹配时,Svelte 提供了一个快捷方式,允许我们将 <div class:active={active}> 简化为 <div class:active>

    aria-pressed={filter === 'all'} 也是类似的情况:当花括号之间传递的 JavaScript 表达式计算结果为真值时,aria-pressed 属性将被添加到按钮中。每当我们点击按钮时,我们都会通过发出 on:click={() => filter = 'all'} 来更新 filter 变量。继续阅读以了解 Svelte 响应性将如何处理其余部分。
  3. 现在我们只需要在 {#each} 循环中使用辅助函数;像这样更新它
    svelte
    <ul role="list" class="todo-list stack-large" aria-labelledby="list-heading">
      {#each filterTodos(filter, todos) as todo (todo.id)}
    在分析我们的代码后,Svelte 检测到我们的 filterTodos() 函数依赖于变量 filtertodos。并且,就像嵌入在标记中的任何其他动态表达式一样,每当这些依赖项中的任何一个发生变化时,DOM 都会相应地更新。因此,每当 filtertodos 发生变化时,filterTodos() 函数将重新计算,循环内的项目将更新。

注意:响应性有时可能很棘手。Svelte 将 filter 识别为依赖项,因为我们在 filterTodos(filter, todo) 表达式中引用了它。filter 是一个顶级变量,因此我们可能会倾向于将其从辅助函数参数中删除,并像这样调用它:filterTodos(todo)。这可以工作,但现在 Svelte 无法知道 {#each filterTodos(todos) } 依赖于 filter,并且当过滤器发生变化时,过滤后的待办事项列表不会更新。始终记住,Svelte 会分析我们的代码以找出依赖项,因此最好明确说明它,而不是依赖于顶级变量的可见性。此外,使我们的代码清晰并明确它正在使用哪些信息是一种良好的实践。

目前的代码

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

总结

现在就到这里!在这篇文章中,我们已经实现了我们的大部分所需功能。我们的应用可以显示、添加和删除待办事项,切换其已完成状态,显示其中有多少已完成,并应用过滤器。

概括地说,我们涵盖了以下主题

  • 创建和使用组件
  • 将静态标记转换为实时模板
  • 在标记中嵌入 JavaScript 表达式
  • 使用 {#each} 指令迭代列表
  • 使用 props 在组件之间传递信息
  • 侦听 DOM 事件
  • 声明响应式语句
  • 使用 console.log() 和响应式语句进行基本调试
  • 使用 bind:property 指令绑定 HTML 属性
  • 使用赋值触发响应性
  • 使用响应式表达式过滤数据
  • 明确定义我们的响应式依赖项

在下一篇文章中,我们将添加更多功能,允许用户编辑待办事项。