🧶

Svelte5の@attach構文とAction比較メモ

に公開

Svelte5.29からuse:ディレクティブを拡張したような{@attach ...}という新しいテンプレート構文が実装されました。以前からあるActionと比較しつつ、どういったものかをある程度調べた備忘メモです。公式サイトに書いてあることを網羅しているわけではないので補足資料の位置付けです。

基本

型定義

// import { type Attachment } from "svelte/attachments";
interface Attachment<T extends EventTarget = Element> {
  (element: T): void | (() => void);
}
// import { type Action, type ActionReturn } from "svelte/action";
interface Action<
    Element = HTMLElement,
    Parameter = undefined,
    Attributes extends Record<string, any> = Record<never, any>
  > {
    <Node extends Element>(
      ...args: undefined extends Parameter
        ? [node: Node, parameter?: Parameter]
        : [node: Node, parameter: Parameter]
    ): void | ActionReturn<Parameter, Attributes>;
  }
interface ActionReturn<
    Parameter = undefined,
    Attributes extends Record<string, any> = Record<never, any>
  > {
    update?: (parameter: Parameter) => void;
    destroy?: () => void;
  }

Actionとの比較

引数がない場合

https://svelte.dev/playground/untitled#H4sIAAAAAAAACo2SwW7bMAyGX4XjekgAI747trG-wA4DdpoGVLHpTJhMGRadNjD07oUlp2naYNhJNsn_50dKM7LuCQv8yWLEUosZdsaSx-LXjHIeltwSwOxS-TgMO38iK0vsoD3dizeOhVg8Flj6ZjSDQO_ayRJYzcdKoXiFtWIlph_cKLC0ghkeGzGOf5BMI0OAbnQ9KEy2uY5Jhfs7OhHd_OmJ5bPqLeWTlJV0E0cv6M-p44YsLSXb4hZhXqqVNI69s7Sz7rh5ShgFPMyraMeupe-6p_C03SfBeCNX0pKX0Z0L2GyhquG9n8I1CckYVleF2yzJQzQNH8Hf5noHn-xPzrT30aPmv9D_CXrd9hX2Alnm6cJrxZer_3jnlgQOzlmo4MGLFtp02npKFktLAceNNc1fqFaQtfzLcu5vmiguD5OI4_gWKoXpTyHMq0moxR2Plso8paJo_mq66Br3Wg4weSouz6FOR5kPkbdszQnmb2nsm9WH-vpd5q051Yrn3HRBMWYo9CJYyDhR-J2haGOfDbdYxGnDK4BqBsh8AwAA

code
<script module lang="ts">
  import type { ActionReturn } from "svelte/action";
  import type { Attachment } from "svelte/attachments";

  function myAction(element): ActionReturn {
    console.log(`action: ${element.nodeName}`);
    return {
      destroy: () => console.log("destroy action element"),
    };
  }
  function myAttachment(element): () => void {
    console.log(`attach: ${element.nodeName}`);
    return () => console.log("destroy attachment element");
  }
</script>
<script lang="ts">
  let bool = $state(false);
  const onclick = () => bool = !bool;
</script>

<button type="button" {onclick}>toggle</button>

{#if bool}
  <p use:myAction>Action</p>
  <div {@attach myAttachment}>Attachment</div>
{/if}

引数がある場合

https://svelte.dev/playground/untitled#H4sIAAAAAAAACo1SS4_TMBD-K9Ohh0SKGsHRTSL2gLQnDkicMNKmiVMsHDuKJ4WV5f-ObCctXRbEJQ_PzPfyONTtKJDhZ02SlOixwEEqYZF9cUjPU6iFAyy2zodpOtiLUBTOTq0Vr513RpPQZJFhZbtZTgSj6RclQLX6XHMky7HhmpMcJzMTBCpw8NCRNPqToGXW4GGYzQgcE2zZxiLH4ytzRG33bRSa_py6lmwa1ZyGRUcsGJ8TYyaUCC0FTO3cjpaBAxI_iYGlWepzAXoZWXicxAw-Z_dKXQDl1BltjRIHZc7ZU1LLYO8S5CHg-duvXkYP2d6tzAdtevGxHYXPn_Jjwpvv0Dn1wtJsnhlkOdQN_E7HcS1C4oUVlmNepHEfQf1L-9d0sr_6DW5v-UY1AKu2LTgGH9JHVHZVfJ9IBAmJbFH8RwbXFP7p-Sbv5vuYhF59V2XaxIbrbSdfLqMSBCdjFNSwt9SSyIZWWZGgAjWB0Z2S3XeoV0Fr-y68j3ckXFenhcjouKQ1x_THEdwK4hsy57MSVZlKcci9kUNEjVdVTbBYwbY9rd22lxwfhVJmt-O4XtZb8L5JbVU5RT9VLy_g3qd47m-b46NMw-9y39wKVdnLS8O1K-XgucYCAx8ymhfhvxZIrVQ_pO6RxWj8L3yjORVCBAAA

code
<script module lang="ts">
  import type { ActionReturn } from "svelte/action";
  import type { Attachment } from "svelte/attachments";

  function myAction(element, params: { text: string, num: number }): ActionReturn {
    console.log(`action: ${params.text} ${params.num} (${element.nodeName})`);
    return {
      destroy: () => console.log("destroy action element"),
    };
  }
  function myAttachment(text: string, num: number): Attachment {
    return (element: Element) => {
      console.log(`attach: ${text} ${num} (${element.nodeName})`);
      return () => console.log("destroy attachment element");
    };
  }
</script>
<script lang="ts">
  let bool = $state(false);
  const onclick = () => bool = !bool;
</script>

<button type="button" {onclick}>toggle</button>

{#if bool}
  <p use:myAction={{ text: "Hello!!", num: 1 }}>Action</p>
  <div {@attach myAttachment("Hi!!", 2)}>Attachment</div>
{/if}

リアクティビティ比較

https://svelte.dev/playground/untitled#H4sIAAAAAAAACqVUTYvbMBD9K4PIIYGQ3LWxt6EU9lzaU1WI11ZSUVky1njbYPTfizSWPzYpbLcHY1kz8-a9ebJ6ZopaMs6-GlSoZcW27Ky0dIx_6xlemxALG2ybMo9Ns3MvUmPYey6cvLdfWoPSoGOcHVzZqgahtlWnJejCXDLB0AmWCwOg6sa2CKEV9HAsUVnzWWLXGvBwbm0NghHsvohBwR7u1CEW5Y9aGrytGkMulh72RCgXJlFbchKoJQLK3wgZrBwWKNeCPSnBNg8pWlpt23m4ldU8TlSnhPpKymYZkdYiYyS6iQpLaxwC2stFy49Dv_UGsnzsPryzDAYC8AiCPetOCgY8bcaWc7AvJI2wBp30ikhBaQR6klrbASls3gAdk0qCGkWnRZZBEg6PIOsG0xcfA7egaTADaPpMCwKd_E7A0w5fJMQG4Tl3hpqn1mupZcjYQlO0Re049HEOHBy2ylzAb_jyRPbBF3LGarnT9rI-KaOS3xxWPUHtAo4_kd0CV_J8liWuSVJPmwKH_juHVy13ydXTqo9LfxqqPZ0HgHbBAqCSDlt75cOoer-liI_5fiF6Nv136Q5E5roFi4iQfsokNXG8JRdpCfSB29yL0an1nESgMFnaLwaQBHD4RIs0VhrLHXtGpGDR0pu_uvNmf2YOTRQH0Q-vHJlJf3Vs_189cblr0ohFRr2J6uyiFObw3CFaE-_bTDD6EgysKbUqf2b9dLX4PCg57Cknf3NtvON8Hmf779V0Xn1O5_Ed9XFEPqdRzeqFOTTQOckJOevpZwHv8-PQqwltKvUC_YfhlqJXtHTj88nKw75SL7kwbMtCjHFsO-m_bxkWSv9SpmL8XGgn_R-b7fzGmQcAAA==

code
<script module lang="ts">
  import type { ActionReturn } from "svelte/action";
  import type { Attachment } from "svelte/attachments";
</script>
<script lang="ts">
  let text = $state("Hi");
  let color = $state("red");
  let action = $state(myAction);
  let attach = $state(myAttachment);
  const toggleColor = () => color = color === "red" ? "blue" : "red";
  const toggleText = () => text = text === "Hi" ? "Hello" : "Hi";
  const toggleAction = () => action = action === myAction ? emptyAction : myAction;
  const toggleAttach = () => attach = attach === myAttachment ? emptyAttachment : myAttachment;
  
  function myAction(element, params: { text: string }): ActionReturn {
    console.log(`init action: ${params.text}`);
    $effect(() => {
      element.style.color = `${color}`;
    });
    return {
      destroy: () => {},
    };
  }
  function emptyAction(element, params: { text: string }): ActionReturn {
    console.log("empty action");
    return { destroy: () => {} };
  }
  function myAttachment(text: string): Attachment {
    return (element: Element) => {
      console.log(`init attachment: ${text}`);
      $effect(() => {
        element.style.color = `${color}`;
      });
      return () => {};
    };
  }
  function emptyAttachment(text: string): Attachment {
    return (element: Element) => {
      console.log("empty attachment");
      return () => {};
    };
  }
</script>

<button type="button" onclick={toggleText}>text</button>
<button type="button" onclick={toggleColor}>color</button>
<button type="button" onclick={toggleAction}>action</button>
<button type="button" onclick={toggleAttach}>attach</button>

<p use:action={{ text }}>Action</p>
<div {@attach attach(text)}>Attachment</div>

Falsy値の許容

Actionではエラーになる。

https://svelte.dev/playground/untitled?version=5.34.9#H4sIAAAAAAAACn2MQW7CMBBFr-LOikoJ2bvGwCG6qrsw9qSM5EwsPAlFUe6OUoQqKtTVfP3_5k3AvkPQ8M5CkjBCBS0lLKA_JpBLXralgOpO7nNelxGTLN3BF3zWh54FWQpoMCWcKItKnr82DqQ4sI6dhJ6LqO6yD0I9q40aOGJLjPHtYRbx4dghyx_ENDfxImPzUtfKZDUU1HelvR3TZKvq2jo2kUY17fyP8UE9299smkjjv_R2vXp99gEVCH4LaDkNOH9WIJ7SmTiCbn0qOF8Bi8PbK24BAAA=

code
<script lang="ts">
  const myAction = undefined;
  const myAttachment = undefined;
</script>

<!-- <p use:myAction>Action</p> -->
<div {@attach myAttachment}>Attachment</div>
<div {@attach myAttachment?.()}>Attachment</div>

コンポーネントへの受け渡し

通常の受け渡し

https://svelte.dev/playground/untitled#H4sIAAAAAAAACrVUTY_bIBD9K7M0h1iy4juxra6qVnusKvVUKi3r4BQVg2Um264Q_70C_Jmkp6qnODO892aGxziieScIJV81SlTiRHLSSiUsod8cwbc-5EKA5NPJx74_2FehMMReuBX34o3RKDRaQklpm0H2CJ05XZQAxfW5YgQtIzXTALLrzYAQpMDBY4PS6C8CL4MGD-1gOmAk0RY8Jhk53sEh8uZHJzTeouaUjVCGI_SD6XqjA2QEHIo5NHaSABqgvegoDt1bKnEvlAicOfR84J2l4ADFb6RgcZD6DD6j225c4AFojLZGiYMy5_1z6ojCziWWQ6Dwz9kxnR02SICTsDiYNwr7DKoanM9Txsfz_m6hn8bAfl3dXFvgZshwVJq6ivS3raYWwWf39eZB32gtt-M2nU3UFD6uhOd-t7OaScK8toNaCMfBHK8GUxbJhTXTkx-vjagEwosxCirYWeQo9i1XViSJUAmC0Y2SzU-oRp3x-EP4PW5EmC5fLohGR4NWjKR_jIAbSXyN5nxWoixSKoLcO9lG1jjbsoeLFXS6h8pNFmPkSShlHh4YAe_rlC6LPvZRnuQruPdpXNtrYeRJBlDm6yVaFif5GtSxnO2fTMFH2RsrMbK8nbmSzI-omTki1-orlFwgRc20K2TrmSY5CR0SisNF-PwvG-j6lW730J3sv22j_7eHNIqh5Y2Az4Pp7fQWp6WQ1I_XU10_p5i8a-94ocm1DhJjDnxVHR1FK9j14WOf3Tg42S-BR5MtSzPZbWu2hX9tsDUmWW17zd9zglyqX1KfCI1vzv8BDZxONZcGAAA=

code
App.svelte
<script module lang="ts">
  import type { ActionReturn } from "svelte/action";
  import type { Attachment } from "svelte/attachments";
  import Component from "./Component.svelte";

  function myAction(element, params: { text: string }): ActionReturn {
    console.log(`action: ${params.text}`);
    return {
      destroy: () => {},
    };
  }
  function myActionFunction(text: string): Action {
    return (element) => myAction(element, { text });
  }
  function myAttachment(text: string): Attachment {
    return (element: Element) => {
      console.log(`attachment: ${text}`);
      return () => {};
    };
  }
</script>
<script lang="ts">
  let bool = $state(false);
  const onclick = () => bool = !bool;
</script>

<button type="button" {onclick}>toggle</button>

{#if bool}
  <p use:myAction={{ text: "Hello!!" }}>Action</p>
  <div {@attach myAttachment("Hi!!")}>Attachment</div>
  <Component
    action={myActionFunction("Component Hello!!")}
    attachment={myAttachment("Component Hi!!")}
  />
{/if}
Component.svelte
<script module lang="ts">
  import type { Action } from "svelte/action";
  import type { Attachment } from "svelte/attachments";
  interface Props {
    action: Action;
    attachment: Attachment;
  }
</script>
<script>
  const { action, attachment }: Props = $props();
</script>

<p use:action>Action Component</p>
<div {@attach attachment}>Attachment Component</div>

スプレッド構文での受け渡し

Actionでは不可能。

https://svelte.dev/playground/untitled#H4sIAAAAAAAACpVSTY-bMBD9K9NpDkRCcGcBaVX11EsvPa1XWkKG1KqxEZ6kjZD_e2UbQtjuStuT4c3Hm3nzJtRNT1jgD82SFR0xxU4qslg8TcjXwcc8gOmS-TgMmb2QYo8dGktv4a3RTJotFljadpQDQ2-OZ0WgGn2qBLIVWAsNIPvBjAwTeDJ4ZG7anz1pTqEdqWFakW90BQfdaHoQGJny5ha1Ah_u-n0x_WA0aV4KsvwGzWOGAl_SnXXL0mjorytbwvSHC7A8Sn3aF3eTweSLAEbi86ghIUUeLuBr_NhDVS85AK3R1ijKlDklL-u4Bewmz-Be9g9L6tIwNnAzHl8ndJlHJWuhF01fi6mI4WCMggp2lhumpGuUpUjhJ2EwulWy_QXVzDOnf_LvrEdMHEYzWKiWTaIcAm8qCkxj5OmNQyX752Ir56Zyn_67k9Dl4cxsdHBCJTD-CYRpntnVbE4nRWUeQ6Fo-iy7sITzLcv17lOWZWEHB3kt9JTLzmGKfg8seDyTS99x-GujbH3-RvS_3R68Pt176gPG1kxj17QE38NlNneJNp0dc--yleJ9G9Xr0afQL4Usy0aybMEVM10Fu6Bm4s20PdtRXoLaocLV0ddlfpSXeiv4c4rcSPVb6iMWwZnuL7CCIF6BBAAA

code
App.svelte
<script module lang="ts">
  import { type Attachment, createAttachmentKey } from "svelte/attachments";
  import Component from "./Component.svelte";

  function myAttachment(text: string): Attachment {
    return (element: Element) => {
      console.log(`attachment: ${text}`);
      return () => {};
    };
  }
</script>
<script lang="ts">
  let bool = $state(false);
  const onclick = () => bool = !bool;

  const props = {
    text: "Component",
    [createAttachmentKey()]: myAttachment("Component"),
  }
</script>

<button type="button" {onclick}>toggle</button>

{#if bool}
  <Component {...props} />
{/if}
Component.svelte
<script module lang="ts">
  import type { Attachment } from "svelte/attachments";
  interface Props {
    text: string;
    attachment: Attachment;
  }
</script>
<script>
  const { text, ...rests }: Props = $props();
</script>

<div {...rests}>{text}</div>

複数のAttachment受け渡し

https://svelte.dev/playground/untitled#H4sIAAAAAAAACrVUTW_bMAz9KxyXgw0ENpKjG7srhgI9bMAO26kqUMeRU2EyZVhM18LQfx_kj9hJ00MH7CSb5CPfk0i2SHklMcFfxIq13OESS6WlxeS-RX6tvc8bcDlG3tR1ZJ-lZm_b5lZesheGWBJbTHBji0bVDJXZHbQEndM-FchWYCYIQFW1aRha8MXghjkvnipJDA7KxlQgsE8a50eXFXglSPAA_Wqq2pCHDIAoPpoGRj2AAMoDFawMQfU6lVoFLF84AcuNon2YzFm0HgXQSD40BIHU0psTuO0_QkizMQagMGSNlpE2--Bx4rtKYNH6Eu4xvBpjx4xdhjlQ4E5abswrrASO8a473bsS1v9ZwvofJawvSBB8lFCYaqtITmRtEEXR7KXnQu4f3ugSfEHX3c_v3860-bheG0OhZU6H2kIKs0JRldfBiw9_GTOF4dWIPFE66B3SRKVpbvPiaQBfR8ER57rTCdrE_QxkgsZpOB8DLRm2xmhIYWE5ZxmUubayv7yeuKFCq-I3pAODIfyTP7tKfdgkCtJLF9y_xmn_C7yTWhuB4fKte-3dSmDoXZ7QTI6gzfbAbKgb31Rg_ycQ2oGuy9js91pu4t7VgdrPquz4dx29mSa4ndg7iDNBbaxKJwiX6LsPE24O0i3fWU3nY3-6oC54P7ymuiXVfnBNEcumzAsJPxpT274fBU-B1_O2fr9nsumN2_kru2RInMKi9h_B20faqWdov_SgGdZ3q8uOF7OJd-o5O7_thyVyrvQfRTtMuq50fwEg9MKiNwYAAA==

code
App.svelte
<script module lang="ts">
  import { type Attachment } from "svelte/attachments";
  import Component from "./Component.svelte";

  function myAttachment1(text: string): Attachment {
    return (element: Element) => {
      console.log(`attachment1: ${text}`);
      return () => console.log("destroy 1");
    };
  }
  function myAttachment2(text: string): Attachment {
    return (element: Element) => {
      console.log(`attachment2: ${text}`);
      return () => console.log("destroy 2");
    };
  }
  function combineAttachments(...attachments: Attachment[]): Attachment {
    return (element: HTMLElement) => {
      const cleanups = attachments.map(x => x(element));
      return () => () => cleanups.forEach(x => x?.());
    };
  }
</script>
<script lang="ts">
  let bool = $state(false);
  const onclick = () => bool = !bool;
  const attachment = combineAttachments(
    myAttachment1("Hello"),
    myAttachment2("Hi")
  );
</script>

<button type="button" {onclick}>toggle</button>

{#if bool}
  <Component {attachment} />
{/if}
<script module lang="ts">
  import type { Attachment } from "svelte/attachments";
  interface Props {
    attachment?: Attachment;
  }
</script>
<script>
  const { attachment }: Props = $props();
</script>

<div {@attach attachment?.()}>Component</div>

雑記

通常の要素とコンポーネントで統一的に扱えるようになり、かなりすっきり書けるようになった印象です。個人的にはFalsyな値がエラーにならなくなったのが非常に嬉しいです。HTML要素の中に{@attach ...}が出てくるのは違和感が凄いですが、慣れだと思うので積極的に使っていきたいと思います。

参考文献

Discussion