Svelte 高级教程:响应式、生命周期、可访问性

在上一篇文章中,我们为待办事项列表添加了更多功能,并开始将我们的应用程序组织成组件。在本文中,我们将添加应用程序的最终功能并进一步将我们的应用程序组件化。我们将学习如何处理与更新对象和数组相关的反应性问题。为了避免常见的陷阱,我们将不得不更深入地了解 Svelte 的反应性系统。我们还将探讨解决一些可访问性焦点问题,以及其他更多内容。

先决条件

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

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

目标 学习一些高级的 Svelte 技术,包括解决反应性问题、与组件生命周期相关的键盘可访问性问题等等。

我们将重点关注一些涉及焦点管理的可访问性问题。为此,我们将利用一些访问 DOM 节点和执行 focus()select() 等方法的技术。我们还将了解如何在 DOM 元素上声明和清理事件监听器。

我们还需要了解一些关于组件生命周期的知识,以了解这些 DOM 节点何时从 DOM 中挂载和卸载,以及我们如何访问它们。我们还将学习 action 指令,它将允许我们以可重用和声明的方式扩展 HTML 元素的功能。

最后,我们将进一步了解组件。到目前为止,我们已经了解了组件如何使用 props 共享数据,以及如何使用事件和双向数据绑定与父组件通信。现在我们将了解组件如何公开方法和变量。

在本文的过程中,将开发以下新组件

  • MoreActions:显示“全选”和“删除已完成”按钮,并发出处理其功能所需的相关事件。
  • NewTodo:显示用于添加新待办事项的 <input> 字段和“添加”按钮。
  • TodosStatus:显示“已完成 x 项,共 y 项”状态标题。

与我们一起编写代码

Git

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

bash
git clone https://github.com/opensas/mdn-svelte-tutorial.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

处理 MoreActions 组件

现在我们将处理“全选”和“删除已完成”按钮。让我们创建一个组件来负责显示按钮并发出相应的事件。

  1. 创建一个新文件,components/MoreActions.svelte
  2. 当第一个按钮被点击时,我们将发出一个 checkAll 事件来表示所有待办事项应该被选中/取消选中。当第二个按钮被点击时,我们将发出一个 removeCompleted 事件来表示所有已完成的待办事项应该被删除。将以下内容放入您的 MoreActions.svelte 文件中
    svelte
    <script>
      import { createEventDispatcher } from "svelte";
      const dispatch = createEventDispatcher();
    
      let completed = true;
    
      const checkAll = () => {
        dispatch("checkAll", completed);
        completed = !completed;
      };
    
      const removeCompleted = () => dispatch("removeCompleted");
    </script>
    
    <div class="btn-group">
      <button type="button" class="btn btn__primary" on:click={checkAll}>{completed ? 'Check' : 'Uncheck'} all</button>
      <button type="button" class="btn btn__primary" on:click={removeCompleted}>Remove completed</button>
    </div>
    
    我们还包含了一个 completed 变量来在选中和取消选中所有任务之间切换。
  3. 回到 Todos.svelte 中,我们将导入我们的 MoreActions 组件并创建两个函数来处理 MoreActions 组件发出的事件。在现有的导入语句下方添加以下导入语句
    js
    import MoreActions from "./MoreActions.svelte";
    
  4. 然后在 <script> 部分的末尾添加描述的函数
    js
    const checkAllTodos = (completed) =>
      todos.forEach((t) => (t.completed = completed));
    
    const removeCompletedTodos = () =>
      (todos = todos.filter((t) => !t.completed));
    
  5. 现在转到 Todos.svelte 标记部分的底部,并将我们复制到 MoreActions.svelte 中的 <div class="btn-group"> 元素替换为对 MoreActions 组件的调用,如下所示
    svelte
    <!-- MoreActions -->
    <MoreActions
      on:checkAll={(e) => checkAllTodos(e.detail)}
      on:removeCompleted={removeCompletedTodos}
    />
    
  6. 好的,让我们回到应用程序中并试一试。您会发现“删除已完成”按钮工作正常,但“全选”/“取消全选”按钮只是静默失败。

要找出这里发生了什么,我们将不得不更深入地了解 Svelte 的反应性。

响应式陷阱:更新对象和数组

要查看发生了什么,我们可以将 checkAllTodos() 函数中的 todos 数组记录到控制台。

  1. 将您现有的 checkAllTodos() 函数更新为以下内容
    js
    const checkAllTodos = (completed) => {
      todos.forEach((t) => (t.completed = completed));
      console.log("todos", todos);
    };
    
  2. 返回浏览器,打开您的 DevTools 控制台,然后点击“全选”/“取消全选”几次。

您会注意到每次按下按钮时数组都会成功更新(todo 对象的 completed 属性在 truefalse 之间切换),但 Svelte 并不知道这一点。这也意味着在这种情况下,像 $: console.log('todos', todos) 这样的反应性语句不会很有用。

要找出为什么会发生这种情况,我们需要了解当更新数组和对象时 Svelte 中的反应性是如何工作的。

许多 Web 框架使用虚拟 DOM 技术来更新页面。基本上,虚拟 DOM 是网页内容的内存副本。框架更新此虚拟表示,然后将其与“真实”DOM 同步。这比直接更新 DOM 快得多,并允许框架应用许多优化技术。

这些框架默认情况下基本上会在每次更改时针对此虚拟 DOM 重新运行我们所有的 JavaScript,并应用不同的方法来缓存昂贵的计算并优化执行。它们几乎没有尝试理解我们的 JavaScript 代码在做什么。

Svelte 不使用虚拟 DOM 表示。相反,它解析和分析我们的代码,创建依赖树,然后生成所需的 JavaScript 以仅更新需要更新的 DOM 部分。这种方法通常会生成具有最少开销的最佳 JavaScript 代码,但它也有其局限性。

有时 Svelte 无法检测到正在监视的变量的更改。请记住,要告诉 Svelte 变量已更改,您必须为其分配一个新值。一个需要记住的简单规则是:**更新的变量的名称必须出现在赋值的左侧。**

例如,在以下代码段中

js
const foo = obj.foo;
foo.bar = "baz";

Svelte 不会更新对 obj.foo.bar 的引用,除非您随后使用 obj = obj。这是因为 Svelte 无法跟踪对象引用,因此我们必须通过发出赋值来明确告诉它 obj 已更改。

**注意:**如果 foo 是一个顶级变量,您可以轻松地告诉 Svelte 在 foo 更改时更新 obj,使用以下反应性语句:$: foo, obj = obj。通过此操作,我们定义了 foo 作为依赖项,并且每当它更改时,Svelte 都会运行 obj = obj

在我们的 checkAllTodos() 函数中,当我们运行

js
todos.forEach((t) => (t.completed = completed));

Svelte 不会将 todos 标记为已更改,因为它不知道当我们在 forEach() 方法中更新我们的 t 变量时,我们也在修改 todos 数组。这很有道理,因为否则 Svelte 将会了解 forEach() 方法的内部工作原理;因此,对于附加到任何对象或数组的任何方法来说也是如此。

然而,我们可以应用不同的技术来解决此问题,并且所有这些技术都涉及为正在监视的变量分配一个新值。

正如我们已经看到的,我们可以简单地告诉 Svelte 使用自我赋值来更新变量,如下所示

js
const checkAllTodos = (completed) => {
  todos.forEach((t) => (t.completed = completed));
  todos = todos;
};

这将解决问题。在内部,Svelte 将 todos 标记为已更改并删除明显多余的自我赋值。除了看起来很奇怪之外,使用这种技术完全没问题,有时它是做到这一点最简洁的方式。

我们还可以通过索引访问 todos 数组,如下所示

js
const checkAllTodos = (completed) => {
  todos.forEach((t, i) => (todos[i].completed = completed));
};

对数组和对象的属性的赋值(例如 obj.foo += 1array[i] = x)与对值本身的赋值的工作方式相同。当 Svelte 分析此代码时,它可以检测到 todos 数组正在被修改。

另一种解决方案是为 todos 分配一个新数组,其中包含所有待办事项的副本,并相应地更新了 completed 属性,如下所示

js
const checkAllTodos = (completed) => {
  todos = todos.map((t) => ({ ...t, completed }));
};

在这种情况下,我们使用的是 map() 方法,该方法返回一个新数组,其中包含对每个项目执行提供的函数的结果。该函数使用 扩展语法 返回每个待办事项的副本,并相应地覆盖 completed 值的属性。此解决方案的额外好处是返回一个包含新对象的新数组,完全避免了修改原始 todos 数组。

**注意:**Svelte 允许我们指定影响编译器工作方式的不同选项。<svelte:options immutable={true}/> 选项告诉编译器您承诺不会更改任何对象。这允许它在检查值是否已更改方面不那么保守,并生成更简单且性能更高的代码。有关 <svelte:options> 的更多信息,请查看 Svelte 选项文档

所有这些解决方案都涉及一个赋值,其中更新的变量位于等式的左侧。任何这些技术都将允许 Svelte 注意到我们的 todos 数组已被修改。

选择一个,并根据需要更新您的 checkAllTodos() 函数。现在您应该能够一次选中和取消选中所有待办事项。试试看!

完成我们的 MoreActions 组件

我们将向我们的组件添加一个可用性细节。当没有要处理的任务时,我们将禁用按钮。要创建此功能,我们将接收 todos 数组作为 prop,并相应地设置每个按钮的 disabled 属性。

  1. 像这样更新您的 MoreActions.svelte 组件
    svelte
    <script>
      import { createEventDispatcher } from 'svelte';
      const dispatch = createEventDispatcher();
    
      export let todos;
    
      let completed = true;
    
      const checkAll = () => {
        dispatch('checkAll', completed);
        completed = !completed;
      }
    
      const removeCompleted = () => dispatch('removeCompleted');
    
      $: completedTodos = todos.filter((t) => t.completed).length;
    </script>
    
    <div class="btn-group">
      <button type="button" class="btn btn__primary"
        disabled={todos.length === 0} on:click={checkAll}>{completed ? 'Check' : 'Uncheck'} all</button>
      <button type="button" class="btn btn__primary"
        disabled={completedTodos === 0} on:click={removeCompleted}>Remove completed</button>
    </div>
    
    我们还声明了一个反应性的 completedTodos 变量来启用或禁用“删除已完成”按钮。
  2. 不要忘记在 Todos.svelte 中调用组件的位置将 prop 传递到 MoreActions
    svelte
    <MoreActions {todos}
        on:checkAll={(e) => checkAllTodos(e.detail)}
        on:removeCompleted={removeCompletedTodos}
      />
    

使用 DOM:关注细节

现在我们已经完成了应用程序所需的所有功能,我们将专注于一些可访问性功能,这些功能将提高键盘用户和屏幕阅读器用户的应用程序可用性。

在当前状态下,我们的应用程序存在一些涉及焦点管理的键盘可访问性问题。让我们看看这些问题。

探索待办事项应用程序中的键盘可访问性问题

现在,键盘用户会发现我们应用程序的焦点流程不太可预测或连贯。

如果您点击应用程序顶部的输入框,您会看到该输入框周围有一个粗的虚线轮廓。此轮廓是您浏览器当前聚焦于此元素的视觉指示器。

如果您是鼠标用户,您可能已经跳过了此视觉提示。但是,如果您只使用键盘工作,则了解哪个控件具有焦点至关重要。它告诉我们哪个控件将接收我们的按键。

如果您反复按下 Tab 键,您会看到虚线焦点指示器在页面上的所有可聚焦元素之间循环。如果您将焦点移动到“编辑”按钮并按下 Enter,焦点会突然消失,您将无法再判断哪个控件将接收我们的按键。

此外,如果您按下EscapeEnter键,则不会发生任何事情。如果您点击取消保存,焦点又会消失。对于使用键盘的用户来说,这种行为充其量只会令人困惑。

我们还希望添加一些可用性功能,例如在必填字段为空时禁用保存按钮,将焦点置于某些HTML元素或在文本输入获得焦点时自动选择内容。

为了实现所有这些功能,我们需要以编程方式访问DOM节点以运行诸如focus()select()之类的函数。我们还必须使用addEventListener()removeEventListener()在控件获得焦点时运行特定的任务。

问题是所有这些DOM节点都是由Svelte在运行时动态创建的。因此,我们必须等待它们被创建并添加到DOM中才能使用它们。为此,我们必须了解组件生命周期以了解何时可以访问它们——稍后将详细介绍。

创建 NewTodo 组件

让我们首先将新的待办事项表单提取到它自己的组件中。根据我们目前所知,我们可以创建一个新的组件文件并调整代码以发出addTodo事件,并将新待办事项的名称与其他详细信息一起传递。

  1. 创建一个新文件,components/NewTodo.svelte
  2. 将以下内容放入其中
    svelte
    <script>
      import { createEventDispatcher } from 'svelte';
      const dispatch = createEventDispatcher();
    
      let name = '';
    
      const addTodo = () => {
        dispatch('addTodo', name);
        name = '';
      }
    
      const onCancel = () => name = '';
    
    </script>
    
    <form on:submit|preventDefault={addTodo} on:keydown={(e) => e.key === 'Escape' && onCancel()}>
      <h2 class="label-wrapper">
        <label for="todo-0" class="label__lg">What needs to be done?</label>
      </h2>
      <input bind:value={name} type="text" id="todo-0" autoComplete="off" class="input input__lg" />
      <button type="submit" disabled={!name} class="btn btn__primary btn__lg">Add</button>
    </form>
    
    在这里,我们使用bind:value={name}<input>绑定到name变量,并在其为空(即没有文本内容)时使用disabled={!name}禁用添加按钮。我们还使用on:keydown={(e) => e.key === 'Escape' && onCancel()}处理Escape键。每当按下Escape键时,我们都会运行onCancel(),它只会清除name变量。
  3. 现在,我们必须从Todos组件内部import并使用它,并更新addTodo()函数以接收新待办事项的名称。在Todos.svelte中的其他import语句下方添加以下import语句
    js
    import NewTodo from "./NewTodo.svelte";
    
  4. 并像这样更新addTodo()函数
    js
    function addTodo(name) {
      todos = [...todos, { id: newTodoId, name, completed: false }];
    }
    
    addTodo()现在直接接收新待办事项的名称,因此我们不再需要newTodoName变量来赋予它值。我们的NewTodo组件负责处理这一点。

    注意:{ name }语法只是{ name: name }的简写。这来自JavaScript本身,与Svelte无关,除了为Svelte自己的简写提供了一些灵感。

  5. 最后,对于本节,用对NewTodo组件的调用替换NewTodo表单标记,如下所示
    svelte
    <!-- NewTodo -->
    <NewTodo on:addTodo={(e) => addTodo(e.detail)} />
    

使用 `bind:this={dom_node}` 指令处理 DOM 节点

现在,我们希望NewTodo组件的<input>元素在每次按下添加按钮时重新获得焦点。为此,我们需要对输入的DOM节点进行引用。Svelte提供了一种使用bind:this={dom_node}指令执行此操作的方法。指定后,一旦组件被挂载并且DOM节点被创建,Svelte就会将对DOM节点的引用分配给指定的变量。

我们将创建一个nameEl变量并使用bind:this={nameEl}将其绑定到输入。然后在addTodo()内部,在添加新待办事项后,我们将调用nameEl.focus()以再次将焦点重新置于<input>上。当用户按下Escape键时,我们将使用onCancel()函数执行相同的操作。

像这样更新NewTodo.svelte的内容

svelte
<script>
  import { createEventDispatcher } from 'svelte';
  const dispatch = createEventDispatcher();

  let name = '';
  let nameEl; // reference to the name input DOM node

  const addTodo = () => {
    dispatch('addTodo', name);
    name = '';
    nameEl.focus(); // give focus to the name input
  }

  const onCancel = () => {
    name = '';
    nameEl.focus(); // give focus to the name input
  }
</script>

<form on:submit|preventDefault={addTodo} on:keydown={(e) => e.key === 'Escape' && onCancel()}>
  <h2 class="label-wrapper">
    <label for="todo-0" class="label__lg">What needs to be done?</label>
  </h2>
  <input bind:value={name} bind:this={nameEl} type="text" id="todo-0" autoComplete="off" class="input input__lg" />
  <button type="submit" disabled={!name} class="btn btn__primary btn__lg">Add</button>
</form>

试用一下应用程序:在<input>字段中键入新的待办事项名称,按tab将焦点赋予添加按钮,然后按EnterEscape查看输入如何恢复焦点。

自动聚焦我们的输入

下一个功能将添加到我们的NewTodo组件中,它是一个autofocus属性,它允许我们指定我们希望在页面加载时将焦点置于<input>字段上。

  1. 我们的第一次尝试如下:让我们尝试添加autofocus属性,并仅从<script>块中调用nameEl.focus()。更新NewTodo.svelte<script>部分的第一部分(前四行),使其如下所示
    svelte
    <script>
      import { createEventDispatcher } from 'svelte';
      const dispatch = createEventDispatcher();
    
      export let autofocus = false;
    
      let name = '';
      let nameEl; // reference to the name input DOM node
    
      if (autofocus) nameEl.focus();
    
  2. 现在返回到Todos组件,并将autofocus属性传递到<NewTodo>组件调用中,如下所示
    svelte
    <!-- NewTodo -->
    <NewTodo autofocus on:addTodo={(e) => addTodo(e.detail)} />
    
  3. 如果您现在尝试使用您的应用程序,您会看到页面现在是空白的,并且在您的DevTools Web控制台中,您会看到类似以下内容的错误:TypeError: nameEl is undefined

为了理解这里发生了什么,让我们再谈谈我们之前提到的组件生命周期

组件生命周期和 `onMount()` 函数

当组件被实例化时,Svelte会运行初始化代码(即组件的<script>部分)。但在那一刻,构成组件的所有节点都未附加到DOM,事实上,它们甚至不存在。

那么您如何知道组件何时已被创建并挂载到DOM上呢?答案是每个组件都有一个生命周期,它从创建开始,到销毁结束。有一些函数允许您在生命周期的关键时刻运行代码。

您最常使用的函数是onMount(),它允许我们在组件挂载到DOM上后立即运行回调。让我们试一试,看看nameEl变量发生了什么。

  1. 首先,在NewTodo.svelte<script>部分开头添加以下行
    js
    import { onMount } from "svelte";
    
  2. 并在其末尾添加以下行
    js
    console.log("initializing:", nameEl);
    onMount(() => {
      console.log("mounted:", nameEl);
    });
    
  3. 现在删除if (autofocus) nameEl.focus()行以避免抛出我们之前看到的错误。
  4. 应用程序现在将再次工作,并且您将在控制台中看到以下内容
    initializing: undefined
    mounted: <input id="todo-0" class="input input__lg" type="text" autocomplete="off">
    
    如您所见,在组件初始化时,nameEl未定义,这是有道理的,因为<input>节点甚至还不存在。组件挂载后,Svelte将对<input>DOM节点的引用分配给nameEl变量,这要归功于bind:this={nameEl}指令。
  5. 要使自动聚焦功能正常工作,请将您添加的之前的console.log()/onMount()块替换为此
    js
    onMount(() => autofocus && nameEl.focus()); // if autofocus is true, we run nameEl.focus()
    
  6. 再次访问您的应用程序,您现在将看到<input>字段在页面加载时处于焦点状态。

注意:您可以查看Svelte文档中的其他生命周期函数,并且您可以在交互式教程中看到它们。

使用 `tick()` 函数等待 DOM 更新

现在,我们将处理Todo组件的焦点管理细节。首先,我们希望在按下其编辑按钮进入编辑模式时,Todo组件的编辑<input>获得焦点。与我们之前看到的相同,我们将在Todo.svelte内部创建一个nameEl变量,并在将editing变量设置为true后调用nameEl.focus()

  1. 打开文件components/Todo.svelte并在您的editingname声明下方添加nameEl变量声明
    js
    let nameEl; // reference to the name input DOM node
    
  2. 现在像这样更新您的onEdit()函数
    js
    function onEdit() {
      editing = true; // enter editing mode
      nameEl.focus(); // set focus to name input
    }
    
  3. 最后,通过像这样更新它将nameEl绑定到<input>字段
    svelte
    <input
      bind:value={name}
      bind:this={nameEl}
      type="text"
      id="todo-{todo.id}"
      autocomplete="off"
      class="todo-text" />
    
  4. 但是,当您尝试更新的应用程序时,当您按下待办事项的编辑按钮时,您会在控制台中收到类似“TypeError: nameEl is undefined”的错误。

那么,这里发生了什么?当您更新Svelte中组件的状态时,它不会立即更新DOM。相反,它会等到下一个微任务,看看是否有任何其他需要应用的更改,包括其他组件中的更改。这样做可以避免不必要的工作,并允许浏览器更有效地批量处理事物。

在这种情况下,当editingfalse时,编辑<input>不可见,因为它不存在于DOM中。在onEdit()函数内部,我们设置editing = true,然后立即尝试访问nameEl变量并执行nameEl.focus()。这里的问题是Svelte尚未更新DOM。

解决此问题的一种方法是使用setTimeout()延迟对nameEl.focus()的调用,直到下一个事件循环,并给Svelte机会更新DOM。

现在试试这个

js
function onEdit() {
  editing = true; // enter editing mode
  setTimeout(() => nameEl.focus(), 0); // asynchronous call to set focus to name input
}

以上解决方案有效,但有点笨拙。Svelte提供了一种更好的方法来处理这些情况。tick()函数返回一个promise,一旦任何挂起的状态更改已应用于DOM(或立即,如果没有任何挂起的状态更改),该promise就会解析。让我们现在试试。

  1. 首先,在<script>部分顶部导入tick,与您现有的导入一起
    js
    import { tick } from "svelte";
    
  2. 接下来,使用来自异步函数await调用tick();像这样更新onEdit()
    js
    async function onEdit() {
      editing = true; // enter editing mode
      await tick();
      nameEl.focus();
    }
    
  3. 如果您现在尝试,您会发现一切按预期工作。

注意:要查看使用tick()的另一个示例,请访问Svelte教程

使用 `use:action` 指令向 HTML 元素添加功能

接下来,我们希望名称<input>在获得焦点时自动选择所有文本。此外,我们希望以一种可以轻松地重用于任何HTML <input>并以声明方式应用的方式开发它。我们将使用此需求作为借口来展示Svelte提供给我们的一个非常强大的功能,用于向常规HTML元素添加功能:操作

要选择DOM输入节点的文本,我们必须调用select()。为了在节点获得焦点时调用此函数,我们需要一个类似以下内容的事件侦听器

js
node.addEventListener("focus", (event) => node.select());

而且,为了避免内存泄漏,我们还应该在节点被销毁时调用removeEventListener()函数。

注意:所有这些都只是标准的WebAPI功能;这里没有什么是特定于Svelte的。

我们可以在我们的Todo组件中实现所有这些,无论何时我们将<input>添加到DOM中或从DOM中删除,但我们必须非常小心地在节点添加到DOM后添加事件侦听器,并在节点从DOM中删除之前删除侦听器。此外,我们的解决方案的可重用性不高。

这就是Svelte操作发挥作用的地方。基本上,它们允许我们在元素添加到DOM后以及从DOM中删除后运行函数。

在我们的直接用例中,我们将定义一个名为selectOnFocus()的函数,该函数将接收一个节点作为参数。该函数将向该节点添加一个事件侦听器,以便每当它获得焦点时,它都会选择文本。然后它将返回一个具有destroy属性的对象。destroy属性是Svelte在从DOM中删除节点后将执行的内容。在这里,我们将删除侦听器以确保我们不会留下任何内存泄漏。

  1. 让我们创建函数selectOnFocus()。将以下内容添加到Todo.svelte<script>部分底部
    js
    function selectOnFocus(node) {
      if (node && typeof node.select === "function") {
        // make sure node is defined and has a select() method
        const onFocus = (event) => node.select(); // event handler
        node.addEventListener("focus", onFocus); // when node gets focus call onFocus()
        return {
          destroy: () => node.removeEventListener("focus", onFocus), // this will be executed when the node is removed from the DOM
        };
      }
    }
    
  2. 现在我们需要告诉<input>使用use:action指令使用该函数
    svelte
    <input use:selectOnFocus />
    
    通过这个指令,我们告诉 Svelte 在组件挂载到 DOM 上时立即运行此函数,并将 <input> 的 DOM 节点作为参数传递。它还负责在组件从 DOM 中移除时执行 destroy 函数。因此,使用 use 指令,Svelte 为我们处理了组件的生命周期。在我们的例子中,我们的 <input> 最终会变成这样:如下更新组件的第一个标签/输入对(在编辑模板内)
    svelte
    <label for="todo-{todo.id}" class="todo-label">New name for '{todo.name}'</label>
    <input
      bind:value={name}
      bind:this={nameEl}
      use:selectOnFocus
      type="text"
      id="todo-{todo.id}"
      autocomplete="off"
      class="todo-text" />
    
  3. 让我们试一试。转到你的应用程序,按下待办事项的“编辑”按钮,然后按 Tab 将焦点从 <input> 移开。现在点击 <input>,你会看到整个输入文本被选中。

使操作可重用

现在让我们使这个函数真正地在组件之间可重用。selectOnFocus() 只是一个函数,没有任何依赖于 Todo.svelte 组件,所以我们可以将其提取到一个文件中并在那里使用它。

  1. src 文件夹内创建一个新的文件 actions.js
  2. 给它以下内容
    js
    export function selectOnFocus(node) {
      if (node && typeof node.select === "function") {
        // make sure node is defined and has a select() method
        const onFocus = (event) => node.select(); // event handler
        node.addEventListener("focus", onFocus); // when node gets focus call onFocus()
        return {
          destroy: () => node.removeEventListener("focus", onFocus), // this will be executed when the node is removed from the DOM
        };
      }
    }
    
  3. 现在从 Todo.svelte 内部导入它;在其他导入语句下方添加以下导入语句
    js
    import { selectOnFocus } from "../actions.js";
    
  4. 并删除 Todo.svelte 中的 selectOnFocus() 定义,因为我们不再需要它了。

重用我们的操作

为了演示我们操作的可重用性,让我们在 NewTodo.svelte 中使用它。

  1. 像之前一样,从 actions.js 中导入 selectOnFocus() 到此文件。
    js
    import { selectOnFocus } from "../actions.js";
    
  2. use:selectOnFocus 指令添加到 <input>,如下所示
    svelte
    <input
      bind:value={name}
      bind:this={nameEl}
      use:selectOnFocus
      type="text"
      id="todo-0"
      autocomplete="off"
      class="input input__lg" />
    

通过几行代码,我们可以以非常可重用和声明的方式为常规 HTML 元素添加功能。它只需要一个 import 和一个简短的指令,如 use:selectOnFocus,它清楚地描述了其目的。而且我们可以实现这一点,而无需创建像 TextInputMyInput 或类似的自定义包装器元素。此外,您可以向一个元素添加任意数量的 use:action 指令。

此外,我们不必费力地使用 onMount()onDestroy()tick()——use 指令为我们处理了组件的生命周期。

其他操作改进

在上一节中,在使用 Todo 组件时,我们不得不处理 bind:thistick()async 函数,才能在 <input> 添加到 DOM 后立即为其赋予焦点。

  1. 以下是如何使用操作实现它
    js
    const focusOnInit = (node) =>
      node && typeof node.focus === "function" && node.focus();
    
  2. 然后在我们的标记中,我们只需要添加另一个 use: 指令
    svelte
    <input bind:value={name} use:selectOnFocus use:focusOnInit />
    
  3. 我们的 onEdit() 函数现在可以变得简单得多
    js
    function onEdit() {
      editing = true; // enter editing mode
    }
    

在继续之前,让我们举最后一个例子,回到我们的 Todo.svelte 组件,并在用户按下“保存”或“取消”后将焦点放在“编辑”按钮上。

我们可以尝试再次重用我们的 focusOnInit 操作,将 use:focusOnInit 添加到“编辑”按钮上。但是我们会引入一个细微的错误。当你添加一个新的待办事项时,焦点将放在最近添加的待办事项的“编辑”按钮上。这是因为在创建组件时 focusOnInit 操作正在运行。

这不是我们想要的——我们希望“编辑”按钮仅在用户按下“保存”或“取消”后才获得焦点。

  1. 因此,返回你的 Todo.svelte 文件。
  2. 首先,我们将创建一个名为 editButtonPressed 的标志并将其初始化为 false。将其添加到其他变量定义的下方
    js
    let editButtonPressed = false; // track if edit button has been pressed, to give focus to it after cancel or save
    
  3. 接下来,我们将修改“编辑”按钮的功能以保存此标志,并为其创建操作。像这样更新 onEdit() 函数
    js
    function onEdit() {
      editButtonPressed = true; // user pressed the Edit button, focus will come back to the Edit button
      editing = true; // enter editing mode
    }
    
  4. 在它下面,添加以下 focusEditButton() 的定义
    js
    const focusEditButton = (node) => editButtonPressed && node.focus();
    
  5. 最后,我们在“编辑”按钮上 use focusEditButton 操作,如下所示
    svelte
    <button type="button" class="btn" on:click={onEdit} use:focusEditButton>
      Edit<span class="visually-hidden"> {todo.name}</span>
    </button>
    
  6. 返回并再次尝试你的应用程序。此时,每次“编辑”按钮添加到 DOM 时,都会执行 focusEditButton 操作,但它只会将焦点放在按钮上,前提是 editButtonPressed 标志为 true

注意:我们在这里仅仅触及了操作的皮毛。操作还可以具有响应式参数,并且 Svelte 允许我们检测这些参数中的任何一个何时发生变化。因此,我们可以添加与 Svelte 响应式系统很好地集成的功能。有关操作的更详细介绍,请考虑查看 Svelte 交互式教程Svelte use:action 文档

组件绑定:使用 `bind:this={component}` 指令公开组件方法和变量

还有一个辅助功能上的小问题。当用户按下“删除”按钮时,焦点消失了。

本文中我们将要介绍的最后一个功能涉及在待办事项被删除后将焦点设置到状态标题上。

为什么是状态标题?在这种情况下,具有焦点的元素已被删除,因此没有明确的候选对象来接收焦点。我们选择状态标题是因为它靠近待办事项列表,并且它是一种提供删除任务的视觉反馈的方式,以及向屏幕阅读器用户指示发生了什么。

首先,我们将状态标题提取到它自己的组件中。

  1. 创建一个新文件 components/TodosStatus.svelte
  2. 将以下内容添加到其中
    svelte
    <script>
      export let todos;
    
      $: totalTodos = todos.length;
      $: completedTodos = todos.filter((todo) => todo.completed).length;
    </script>
    
    <h2 id="list-heading">
      {completedTodos} out of {totalTodos} items completed
    </h2>
    
  3. Todos.svelte 的开头导入文件,在其他 import 语句下方添加以下 import 语句
    js
    import TodosStatus from "./TodosStatus.svelte";
    
  4. 用对 TodosStatus 组件的调用替换 Todos.svelte 内的 <h2> 状态标题,并将 todos 作为 prop 传递给它,如下所示
    svelte
    <TodosStatus {todos} />
    
  5. 你还可以进行一些清理,从 Todos.svelte 中删除 totalTodoscompletedTodos 变量。只需删除 $: totalTodos = …$: completedTodos = … 行,并在我们计算 newTodoId 时删除对 totalTodos 的引用,并改为使用 todos.length。为此,请用以下内容替换以 let newTodoId 开头的代码块
    js
    $: newTodoId = todos.length ? Math.max(...todos.map((t) => t.id)) + 1 : 1;
    
  6. 一切按预期工作——我们只是将最后一段标记提取到它自己的组件中。

现在我们需要找到一种方法,在待办事项被删除后将焦点赋予 <h2> 状态标签。

到目前为止,我们已经了解了如何通过 prop 向组件发送信息,以及组件如何通过发出事件或使用双向数据绑定与父组件通信。子组件可以使用 bind:this={dom_node} 获取对 <h2> 节点的引用,并使用双向数据绑定将其暴露到外部。但是这样做会破坏组件的封装;设置焦点应该是它自己的责任。

因此,我们需要 TodosStatus 组件公开一个方法,其父组件可以调用该方法以将其赋予焦点。这是一种非常常见的情况,组件需要向使用者公开某些行为或信息;让我们看看如何用 Svelte 实现它。

我们已经看到 Svelte 使用 export let varname = …声明 prop。但是,如果你不使用 let 而是导出 constclassfunction,则它在组件外部是只读的。但是,函数表达式是有效的 prop。在以下示例中,前三个声明是 prop,其余是导出的值

svelte
<script>
  export let bar = "optional default initial value"; // prop
  export let baz = undefined; // prop
  export let format = (n) => n.toFixed(2); // prop

  // these are readonly
  export const thisIs = "readonly"; // read-only export

  export function greet(name) {
    // read-only export
    alert(`Hello, ${name}!`);
  }

  export const greet = (name) => alert(`Hello, ${name}!`); // read-only export
</script>

考虑到这一点,让我们回到我们的用例。我们将创建一个名为 focus() 的函数,该函数将焦点赋予 <h2> 标题。为此,我们需要一个 headingEl 变量来保存对 DOM 节点的引用,并且我们必须使用 bind:this={headingEl} 将其绑定到 <h2> 元素。我们的焦点方法只会运行 headingEl.focus()

  1. 像这样更新 TodosStatus.svelte 的内容
    svelte
    <script>
      export let todos;
    
      $: totalTodos = todos.length;
      $: completedTodos = todos.filter((todo) => todo.completed).length;
    
      let headingEl;
    
      export function focus() {
        // shorter version: export const focus = () => headingEl.focus()
        headingEl.focus();
      }
    </script>
    
    <h2 id="list-heading" bind:this={headingEl} tabindex="-1">
      {completedTodos} out of {totalTodos} items completed
    </h2>
    
    请注意,我们已向 <h2> 添加了一个 tabindex 属性,以允许元素以编程方式接收焦点。正如我们之前看到的,使用 bind:this={headingEl} 指令使我们能够在 headingEl 变量中获得对 DOM 节点的引用。然后,我们使用 export function focus() 公开一个将焦点赋予 <h2> 标题的函数。我们如何从父级访问这些导出的值?就像你可以使用 bind:this={dom_node} 指令绑定到 DOM 元素一样,你也可以使用 bind:this={component} 绑定到组件实例本身。因此,当你在 HTML 元素上使用 bind:this 时,你会获得对 DOM 节点的引用,而当你对 Svelte 组件执行此操作时,你会获得对该组件实例的引用。
  2. 因此,要绑定到 TodosStatus 的实例,我们首先将在 Todos.svelte 中创建一个 todosStatus 变量。在你的 import 语句下方添加以下行
    js
    let todosStatus; // reference to TodosStatus instance
    
  3. 接下来,向调用中添加 bind:this={todosStatus} 指令,如下所示
    svelte
    <!-- TodosStatus -->
    <TodosStatus bind:this={todosStatus} {todos} />
    
  4. 现在我们可以从 removeTodo() 函数调用 exported focus() 方法
    js
    function removeTodo(todo) {
      todos = todos.filter((t) => t.id !== todo.id);
      todosStatus.focus(); // give focus to status heading
    }
    
  5. 返回你的应用程序。现在,如果你删除任何待办事项,状态标题将获得焦点。这有助于突出待办事项数量的变化,对有视力的人和屏幕阅读器用户都有用。

注意:你可能想知道为什么我们需要为组件绑定声明一个新变量。为什么我们不能简单地调用 TodosStatus.focus()?你可能有多个 TodosStatus 实例处于活动状态,因此你需要一种方法来引用每个特定的实例。这就是为什么你必须指定一个变量来将每个特定实例绑定到的原因。

目前的代码

Git

要查看本文结束时代码的状态,请像这样访问你的 repo 副本

bash
cd mdn-svelte-tutorial/06-stores

或者直接下载文件夹的内容

bash
npx degit opensas/mdn-svelte-tutorial/06-stores

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

REPL

要查看 REPL 中代码的当前状态,请访问

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

总结

在本文中,我们已经完成了向我们的应用程序添加所有必需功能的工作,并且我们还解决了许多辅助功能和可用性问题。我们还完成了将我们的应用程序拆分为可管理的组件,每个组件都具有唯一的职责。

同时,我们看到了几个高级的 Svelte 技术,例如

  • 更新对象和数组时处理响应式问题
  • 使用 bind:this={dom_node}(绑定 DOM 元素)处理 DOM 节点
  • 使用组件生命周期 onMount() 函数
  • 使用 tick() 函数强制 Svelte 解决挂起的状态更改
  • 使用 use:action 指令以可重用和声明的方式向 HTML 元素添加功能
  • 使用 bind:this={component}(绑定组件)访问组件方法

在下一篇文章中,我们将了解如何使用存储在组件之间进行通信,以及如何向我们的组件添加动画。