附件本质上是元素级的生命周期函数。它们对于以下情况很有用:
¥Attachments are essentially element-level lifecycle functions. They’re useful for things like:
与第三方库交互
¥interfacing with third-party libraries
延迟加载的图片
¥lazy-loaded images
tooltips
添加自定义事件处理程序
¥adding custom event handlers
在这个应用中,你可以在 <canvas> 上涂鸦,并通过菜单更改颜色和画笔大小。但如果你打开菜单并使用 Tab 键循环浏览选项,你很快就会发现焦点没有被困在模式中。
¥In this app, you can scribble on the <canvas>, and change colours and brush size via the menu. But if you open the menu and cycle through the options with the Tab key, you’ll soon find that the focus isn’t trapped inside the modal.
我们可以使用附件来解决这个问题。从 attachments.svelte.js 导入 trapFocus...
¥We can fix that with an attachment. Import trapFocus from attachments.svelte.js...
<script>
import Canvas from './Canvas.svelte';
import { trapFocus } from './attachments.svelte.js';
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
let selected = $state(colors[0]);
let size = $state(10);
let showMenu = $state(true);
</script><script lang="ts">
import Canvas from './Canvas.svelte';
import { trapFocus } from './attachments.svelte.js';
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
let selected = $state(colors[0]);
let size = $state(10);
let showMenu = $state(true);
</script>然后使用 {@attach} 标签将其添加到菜单中:
¥...then add it to the menu with the {@attach} tag:
<div class="menu" {@attach trapFocus}>让我们看一下 attachments.svelte.js 中的 trapFocus 函数。当节点挂载到 DOM 时,会使用 node(在本例中为 <div class="menu">)调用附件函数。附件在 effect 内部运行,因此每当函数内部读取的任何状态发生变化时,它们都会重新运行。
¥Let’s take a look at the trapFocus function in attachments.svelte.js. An attachment function is called with a node — the <div class="menu"> in our case — when the node is mounted to the DOM. Attachments run inside an effect, so they re-run whenever any state read inside the function changes.
首先,我们需要添加一个拦截 Tab 键按下的事件监听器:
¥First, we need to add an event listener that intercepts Tab key presses:
focusable()[0]?.focus();
const off = on(node, 'keydown', handleKeydown);
on是addEventListener的封装器,它使用了 event delegation。它返回一个移除处理程序的函数。
其次,我们需要在节点卸载时进行一些清理工作 - 移除事件监听器,并将焦点恢复到元素挂载之前的位置。与 effect 类似,附件可以返回一个清理函数,该函数会在附件重新运行之前或元素从 DOM 中移除之后立即运行:
¥Second, we need to do some cleanup when the node is unmounted — removing the event listener, and restoring focus to where it was before the element mounted. As with effects, an attachment can return a teardown function, which runs immediately before the attachment re-runs or after the element is removed from the DOM:
focusable()[0]?.focus();
const off = on(node, 'keydown', handleKeydown);
return () => {
off();
previous?.focus();
};现在,当你打开菜单时,你可以使用 Tab 键循环浏览选项。
¥Now, when you open the menu, you can cycle through the options with the Tab key.
<script>
import Canvas from './Canvas.svelte';
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
let selected = $state(colors[0]);
let size = $state(10);
let showMenu = $state(true);
</script>
<div class="container">
<Canvas color={selected} size={size} /> {#if showMenu}<div
role="presentation"
class="modal-background"
onclick={(event) => { if (event.target === event.currentTarget) {showMenu = false;
}
}}
onkeydown={(e) => { if (e.key === 'Escape') {showMenu = false;
}
}}
>
<div class="menu">
<div class="colors">
{#each colors as color}<button
class="color"
aria-label={color} aria-current={selected === color} style="--color: {color}" onclick={() => {selected = color;
}}
></button>
{/each}</div>
<label>
small
<input type="range" bind:value={size} min="1" max="50" />large
</label>
</div>
</div>
{/if}<div class="controls">
<button class="show-menu" onclick={() => showMenu = !showMenu}> {showMenu ? 'close' : 'menu'}</button>
</div>
</div>
<style>
.container {position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.controls {position: absolute;
left: 0;
top: 0;
padding: 1em;
}
.show-menu {width: 5em;
}
.modal-background {position: fixed;
display: flex;
justify-content: center;
align-items: center;
left: 0;
top: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(20px);
}
.menu {position: relative;
background: var(--bg-2);
width: calc(100% - 2em);
max-width: 28em;
padding: 1em 1em 0.5em 1em;
border-radius: 1em;
box-sizing: border-box;
user-select: none;
}
.colors {display: grid;
align-items: center;
grid-template-columns: repeat(9, 1fr);
grid-gap: 0.5em;
}
.color {aspect-ratio: 1;
border-radius: 50%;
background: var(--color, #fff);
transform: none;
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.2));
transition: all 0.1s;
}
.color[aria-current="true"] {transform: translate(1px, 1px);
filter: none;
box-shadow: inset 3px 3px 4px rgba(0,0,0,0.2);
}
.menu label {display: flex;
width: 100%;
margin: 1em 0 0 0;
}
.menu input {flex: 1;
}
</style>