파란하늘의 지식창고
article thumbnail
Published 2022. 6. 23. 23:31
Svelte 공부하기 Study/JavaScript
반응형

Svelte 소개

Svelte는 2016년 출시된 오픈소스 프런트엔드 웹 프레임워크이다.

기존에 인기 있는 React, Angular, Vue.js를 이어 다음 주자가 될지 관심을 받고 있다.

https://svelte.dev/

개발 과정에선 Svelte를 사용하지만 빌드 시 vanilla JavaScript로 결과물을 만들어내서 이로 인해 라이브 사용 시 코드 용량이 많이 줄어든다고 한다.

또한 가상 Dom을 사용하지 않고 간소화된 코드로 기존 대비 동일한 결과물을 만들 수 있다고 한다.

React의 Next, Vue의 Nuxt같이 Svelte도 SvelteKit이 있다고 하는데 일단 Svelte가 어떤 라이브러리인지 알아본다.

공부하기

다음과 같이 사용하여 Svelte template 프로젝트를 만들 수 있다.

npm init vite my-app -- --template svelte
cd my-app
npm install
npm run dev

yarn이나 pnpm으로 만들고 싶은 경우 vite의 프로젝트 만들어보기 문서를 참고하면 된다.

https://vitejs-kr.github.io/guide/#scaffolding-your-first-vite-project

인기 있는 라이브러리라서 많은 강좌가 있긴 한데 Svelte가 제공하는 Tutorial이 친절하게 설명하고 있고 직접 코드를 웹에서 만들면서 바로바로 확인해볼 수 있어서 Tutorial로 학습하면서 정리해보려고 한다.

https://svelte.dev/tutorial/basics

tutorial에 설명된 예제들은 Examples에서도 확인할 수 있다.

https://svelte.dev/examples

Tutorial

Introduction

Basics

Svelte란?

Svelte는 빠른 web application을 만들기 위한 도구이다.

React나 Vue 같은 JavaScript Framework와 유사하지만 Svelte는 runtime에 application code를 해석하지 않고 build시 app을 이상적인(ideal) JavaScript로 변환한다.
즉 framework 추상화의 성능 비용이 발생하지 않아 app이 처음 로드될 때 페널티가 발생하지 않는다.

Svelte를 사용해서 전체 app을 빌드하거나 기존 코드 베이스에 점진적으로 추가할 수 있다.
또한 component를 기존 framework에 대한 dependency overhead 없이 어디서나 작동하는 standalone package로 제공할 수 있다.

Component 이해

Svelte에서 application은 하나 이상의 component로 구성된다.
component는 HTML, CSS 및 JavaScript를 캡슐화하는 재사용 가능한 자체 포함 코드 블록으로 .svelte 파일에 작성된다.

Adding data

간단한 사용 예는 다음과 같다.

<script>
    let name = 'world';
</script>

<h1>Hello {name.toUpperCase()}!</h1>

Dynamic attributes

만약 다음과 같이 img 태그를 썼다면

<img>

Svelte에선 다음과 같이 A11y (Accessibility) 경고를 안내한다.

A11y: <img> 요소에는 alt 속성이 있어야 합니다.

web app을 빌드할 때 시각적으로 또는 행동이 불편한 사람 또는 하드웨어가 오래되거나 인터넷 연결이 없는 사람을 포함하여 가능한 가장 광범위한 사용자 기반 접근성이 좋은지 확인하는 것이 중요하기 때문에 이러한 A11y 경고를 표시하여 코드를 작성하는데 도움을 준다.

attribute 내에서 중괄호를 사용하여 변수를 지정할 수 있다.

<script>
    let src = '/tutorial/image.gif';
    let name = 'Rick Astley';
</script>

<img src={src} alt="{name} dances.">

Shorthand attributes

또한 위 예제에서 src의 경우처럼 attribute의 name과 value 가 같은 경우 다음과 같이 축약해서 사용할 수 있다.

<img {src} alt="A man dances.">

Styling

HTML과 마찬가지로 component에 <style> tag를 추가할 수 있다.

<p>This is a paragraph.</p>

<style>
    p {
        color: purple;
        font-family: 'Comic Sans MS', cursive;
        font-size: 2em;
    }
</style>

중요한 것은 규칙의 범위가 component라는 것이다.
위에 지정한 style은 해당 component의 태그에만 적용된다.

Nested components

전체 app을 단일 component로 사용하는 것은 비현실적이다.
다른 파일에서 component를 import 하여 해당 component를 포함하는 것처럼 사용할 수 있다.

아래 component에서

<script>
    import Nested from './Nested.svelte';
</script>

<p>This is a paragraph.</p>
<Nested/>

<style>
    p {
        color: purple;
        font-family: 'Comic Sans MS', cursive;
        font-size: 2em;
    }
</style>

아래의 Nested.svelte를 import 하여 사용하였다.

<p>This is another paragraph.</p>

상위 component에 선언된 style은 import 한 Nested component에 적용되지 않는다.

또한 component의 이름은 대문자로 표시된다.
이 규칙은 component와 일반 html tag를 구분하기 위해 적용되었다.

HTML tags

일반적으로 문자열(string)은 일반 텍스트(plain text)로 삽입된다.
즉, 문자열 내의 <> 는 특별한 의미가 없다.

하지만 때로는 HTML을 component에서 직접 rendering 해야 한다.
이런 경우 Svelte에서는 {@html ...} tag를 사용한다.

<p>{@html string}</p>

Making an app

이 tutorial의 경우 component 작성 과정에 익숙해지도록 설계되었다.
하지만 직접 프로젝트를 만들고 component 작성을 하게 될 것이다.

vite-plugin-svelte와 함께 Vite를 사용하면 좋다.

npm init vite my-app -- --template svelte

또는 community-maintained integrations 중 하나를 사용할 수도 있다.

웹 개발에 비교적 익숙하지 않고 예전에 이런 도구들을 사용한 적이 없는 경우 프로세스를 안내하는 간단한 단계별 가이드인 Svelte for new developers 도 있다.

text editor를 구성하고 싶은 경우 공식 VS Code extension 뿐만 아니라 많은 인기 있는 editor를 위한 plugin이 있다.

프로젝트 설정이 완료되면 Svelte component를 쉽게 사용할 수 있다.
compiler는 각 component를 일반 JavaScript class로 변환한다.
new 로 import 하고 인스턴스화 하기만 하면 된다.

import App from './App.svelte';

const app = new App({
    target: document.body,
    props: {
        // we'll learn about props later
        answer: 42
    }
});

그런 다음 필요한 component API를 사용하여 상호 작용할 수 있다.

Reactivity

Assignments

Svelte의 핵심에는 event에 대한 response와 같이 DOM을 application state와 동기화하기 위한 강력한 reactivity system이 있다.

이를 사용하려면 먼저 event handler에 연결해야 한다.

<script>
    let count = 0;

    function incrementCount() {
        count += 1;
    }
</script>

<button on:click={incrementCount}>

Svelte는 DOM을 업데이트해야 한다고 알려주는 일부 code로 이 할당(assignments)을 계측(instruments)한다.

Declarations

Svelte는 component의 state가 변경되면 DOM을 자동으로 업데이트한다.
종종 component state의 일부는 다른 부분에서 계산해야 하고(예를 들어 firstnamelastname 에서 파생된 fullname ) 변경될 때마다 다시 계산해야 한다.

이를 위한 반응형 선언(reactive declaration)이 있다.
다음과 같이 사용한다.

let count = 0;
$: doubled = count * 2;

markup에서 위에서 선언한 doubled 를 사용해보자.

<p>{count} doubled is {doubled}</p>

물론 반응형 값(reactive value) 대신 {count * 2} 와 같이 사용할 수도 있다.
반응형 값은 여러 번 참조해야 하거나 다른 반응형 값에 의존하는 값이 있는 경우 유용하다.

Statements

반응형 선언은 값뿐만 아니라 임의의 구문(statements)도 사용할 수 있다.
예를 들어 변경될 때마다 count 라는 값을 기록할 수 있다.

block과 함께 구문을 쉽게 그룹화할 수 있다.

$: {
    console.log('the count is ' + count);
    alert('I SAID THE COUNT IS ' + count);
}

if block과 같은 것도 사용할 수 있다.

$: if (count >= 10) {
    alert('count is dangerously high!');
    count = 9;
}

Updating arrays and objects

Svelte의 반응성(reactivity)은 할당(assignment)에 의해 유발(trigger)된다.
array나 object를 변경하는 method는 자체적으로 update를 유발하지 않는다.

예를 들어 숫자 추가 버튼을 클릭하면 호출되는 함수가 다음과 같다면

function addNumber() {
    numbers.push(numbers.length + 1);
}

array에 숫자는 실제 추가되더라도 트리거가 일어나지 않으므로 변경이 일어나지 않는다.
이를 수정하는 한 가지 방법은 compiler에게 변경을 알리기 위해 자신에게 할당하는 것이다.

function addNumber() {
    numbers.push(numbers.length + 1);
    numbers = numbers;
}

ES 6 spread syntax를 사용하여 더 간결하게 작성할 수도 있다.

function addNumber() {
    numbers = [...numbers, numbers.length + 1];
}

동일한 규칙이 pop , shift , splice 같은 array method나 Map.set , Set.add 같은 object method에도 적용된다.

array 및 object의 property에 대한 할당(예를 들어 obj.foo += 1 또는 array[i] = x )은 값 자체에 대한 할당과 동일한 방식으로 동작한다.

function addNumber() {
    numbers[numbers.length] = numbers.length + 1;
}

그러나

const foo = obj.foo;
foo.bar = 'baz';

또는

function quox(thing) {
    thing.foo.bar = 'baz';
}
quox(obj);

같은 참조에 대한 간접 할당은 obj = obj 같은 후속 조치를 취하지 않는 한 obj.foo.bar 의 반응성을 트리거하지 않는다.

요약하면 "업데이트된 변수는 할당의 왼쪽에 직접 명시되어야" 트리거의 대상이 된다.

Props

Declaring props

지금까지 내부 state를 다루었다.
즉, 값은 주어진 component 내에서만 접근할 수 있다.

모든 실제 application에서는 한 component의 data를 자식 component로 전달해야 한다.
그렇게 하려면 일반적으로 'props'로 짧게 줄인 properties를 선언해야 한다.
Svelte에서는 export 키워드로 이를 수행한다.

상위 component에서 다음과 같이 값을 전달하면

<script>
    import Nested from './Nested.svelte';
</script>

<Nested answer={42}/>

자식 component인 Nested.svelte 파일에선 다음과 같이 export로 값을 전달받는다.

<script>
    export let answer;
</script>

<p>The answer is {answer}</p>

결과는 다음과 같다.

The answer is 42

만약 상위 component에서 값을 전달하지 않는다면 결과는 다음과 같다.

The answer is undefined

Default values

또한 기본값을 다음과 같이 정의할 수 있다.

<script>
    export let answer = 'a mystery';
</script>

<p>The answer is {answer}</p>

이 경우 상위 component에서 값을 전달하지 않으면 기본값을 사용한다.

아래처럼 사용하였다면

<Nested answer={42}/>
<Nested/>

결과는 다음과 같다.

The answer is 42

The answer is a mystery

Spread props

property들의 object가 있는 경우 각 property를 명시하는 대신 component에 확산(spread) 할 수 있다.

App.svelte :

<script>
    import Info from './Info.svelte';

    const pkg = {
        name: 'svelte',
        version: 3,
        speed: 'blazing',
        website: 'https://svelte.dev'
    };
</script>

<Info {...pkg}/>

Info.svelte :

<script>
    export let name;
    export let version;
    export let speed;
    export let website;
</script>

<p>
    The <code>{name}</code> package is {speed} fast.
    Download version {version} from <a href="https://www.npmjs.com/package/{name}">npm</a>
    and <a href={website}>learn more here</a>
</p>

상위 component에서 다음처럼 전달할 property를 개별로 선언하지 않아도 {...pkg}로 object를 전달하면 pkg의 각 property가 확산되어 전달받는다.

<Info name={pkg.name} version={pkg.version} speed={pkg.speed} website={pkg.website}/>

이와 반대로 선언되지 않은 것을 포함하여 component에 전달된 모든 props를 참조해야 하는 경우 직접 export 를 선언하지 않아도 $$props 를 직접 접근하여 사용할 수 있다.
Svelte가 최적화하기 어렵기 때문에 일반적으로 권장되진 않지만 이따금 유용할 수 있다.

Logic

If blocks

HTML에는 조건문 및 순환문 같은 logic을 표현하는 방법이 없다.
Svelte는 다음과 같이 if block으로 감싸서 조건에 따라 렌더링 할 markup을 사용할 수 있다.

<script>
    let user = { loggedIn: false };

    function toggle() {
        user.loggedIn = !user.loggedIn;
    }
</script>

{#if user.loggedIn}
    <button on:click={toggle}>
        Log out
    </button>
{/if}

{#if !user.loggedIn}
    <button on:click={toggle}>
        Log in
    </button>
{/if}

Else blocks

<script>
    let user = { loggedIn: false };

    function toggle() {
        user.loggedIn = !user.loggedIn;
    }
</script>

{#if user.loggedIn}
    <button on:click={toggle}>
        Log out
    </button>
{/if}

{#if !user.loggedIn}
    <button on:click={toggle}>
        Log in
    </button>
{/if}

두 조건문 if user.loggedInif !user.loggedIn 은 서로 상호 배타적이므로 else block을 사용하여 이 구성요소를 단순화할 수 있다.

<script>
    let user = { loggedIn: false };

    function toggle() {
        user.loggedIn = !user.loggedIn;
    }
</script>

{#if user.loggedIn}
    <button on:click={toggle}>
        Log out
    </button>
{:else}
    <button on:click={toggle}>
        Log in
    </button>
{/if}

# 문자는 항상 block의 시작 tag를 나타낸다.
/ 문자는 항상 block의 닫기 tag를 나타낸다.
: 문자는 block 연속 tag를 나타낸다.

Else-if blocks

여러 조건을 else if 로 함께 연결할 수 있다.

{#if x > 10}
    <p>{x} is greater than 10</p>
{:else if 5 > x}
    <p>{x} is less than 5</p>
{:else}
    <p>{x} is between 5 and 10</p>
{/if}

Each blocks

순환문은 다음과 같이 each block을 사용한다.

<ul>
    {#each cats as cat}
        <li><a target="_blank" href="https://www.youtube.com/watch?v={cat.id}">
            {cat.name}
        </a></li>
    {/each}
</ul>

표현식 (이 경우엔 cats )은 모든 array와 array와 유사한 object 일 수 있다. (즉, length property가 있음)
each [...iterable] 을 사용해 일반적인 iterable을 사용할 수 도 있다.

두 번째 argument로 현재 index를 가져올 수 있다.

{#each cats as cat, i}
    <li><a target="_blank" href="https://www.youtube.com/watch?v={cat.id}">
        {i + 1}: {cat.name}
    </a></li>
{/each}

원하는 경우 destructuring을 사용할 수 있다.
each cats as { id, name } 로 선언하였다면 cat.idcat.nameidname 으로 변경할 수 있다.

Keyed each blocks

기본적으로 each block 값을 수정하면 block 끝에 item을 add, remove 하고 변경된 값이 update 된다.
하지만 원하는 동작이 아닐 수 있다.

예를 들면 다음과 같다.

App.svelte :

<script>
    import Thing from './Thing.svelte';

    let things = [
        { id: 1, name: 'apple' },
        { id: 2, name: 'banana' },
        { id: 3, name: 'carrot' },
        { id: 4, name: 'doughnut' },
        { id: 5, name: 'egg' },
    ];

    function handleClick() {
        things = things.slice(1);
    }
</script>

<button on:click={handleClick}>
    Remove first thing
</button>

{#each things as thing}
    <Thing name={thing.name}/>
{/each}

Thing.svelte :

<script>
    const emojis = {
        apple: "🍎",
        banana: "🍌",
        carrot: "🥕",
        doughnut: "🍩",
        egg: "🥚"
    }

    // the name is updated whenever the prop value changes...
    export let name;

    // ...but the "emoji" variable is fixed upon initialisation of the component
    const emoji = emojis[name];
</script>

<p>
    <span>The emoji for { name } is { emoji }</span>
</p>

<style>
    p {
        margin: 0.8em 0;
    }
    span {
        display: inline-block;
        padding: 0.2em 1em 0.3em;
        text-align: center;
        border-radius: 0.2em;
        background-color: #FFDFD3;
    }
</style>

처음 화면은 다음과 같다.

Remove first thing 버튼을 눌러 첫 번째 배열이 값을 제거하는 동작을 수행하면 다음과 같은 결과가 나온다.

이는 <Thing> componet의 첫 번째 구성요소가 제거되지 않고 마지막 Dom node가 제거되고 변경된 배열이 반영되면서 자식 component에 처리된 이모티콘은 업데이트되지 않아서 발생한 경우이다.

첫 번째 <Thing> component를 제거하고 다른 component는 영향을 받지 않도록 하고 싶은 경우 이를 위해 each block에 고유 식별자(unique identifier, 또는 key)를 지정한다.

{#each things as thing (thing.id)}
    <Thing name={thing.name}/>
{/each}

이 경우 (thing.id) 는 key이다.
component가 update 될 때 변경할 DOM node가 어느 것인지 Svelte에 알려준다.

Svelte는 내부적으로 Map 을 사용하므로 어떤 object라도 key로 사용할 수 있다.
즉 (thing.id) 대신 (thing) 을 사용할 수도 있다.
그러나 문자열이나 숫자를 사용하는 것이 일반적으로 더 안전하다.
예를 들어 API 서버에서 최신 데이터로 업데이트할 때 참조 동일성(referential equality) 없이 ID가 유지된다는 의미이기 때문이다.

Await blocks

대부분의 web application은 어느 시점에 비동기 데이터를 처리해야 한다.
Svelte를 사용하면 markup에서 직접 promise의 값을 쉽게 await 할 수 있다.

<script>
    async function getRandomNumber() {
        const res = await fetch(`/tutorial/random-number`);
        const text = await res.text();

        if (res.ok) {
            return text;
        } else {
            throw new Error(text);
        }
    }

    let promise = getRandomNumber();

    function handleClick() {
        promise = getRandomNumber();
    }
</script>

<button on:click={handleClick}>
    generate random number
</button>

{#await promise}
    <p>...waiting</p>
{:then number}
    <p>The number is {number}</p>
{:catch error}
    <p style="color: red">{error.message}</p>
{/await}

결과는 다음과 같다.

가장 최근의 promise 만 고려되기 때문에 경쟁 조건에 대해 걱정할 필요가 없다.

promise가 reject 될 수 없다는 것을 알고 있다면 catch block을 생략할 수 있다.
promise가 해결될 때까지 아무것도 표시하지 않으려면 첫 번째 block을 생략할 수도 있다.

Events

DOM events

앞서 언급한 예제 중에 보았듯이 on: 지시문을 사용하여 이벤트를 listen 할 수 있다.

<script>
    let m = { x: 0, y: 0 };

    function handleMousemove(event) {
        m.x = event.clientX;
        m.y = event.clientY;
    }
</script>

<div on:mousemove={handleMousemove}>
    The mouse position is {m.x} x {m.y}
</div>

<style>
    div { width: 100%; height: 100%; }
</style>

Inline handlers

event handler를 inline으로 선언할 수도 있다.

<script>
    let m = { x: 0, y: 0 };
</script>

<div on:mousemove="{e => m = { x: e.clientX, y: e.clientY }}">
    The mouse position is {m.x} x {m.y}
</div>

<style>
    div { width: 100%; height: 100%; }
</style>

따옴표는 선택 사항이지만 일부 환경에서 syntax highlighting에 유용하다.

일부 framework에서는 특히 loop 내부와 같은 성능상의 이유로 inline event handler를 피하라는 권장사항을 볼 수 있다.
svelte의 경우는 inline으로 사용해도 상관없다.
compiler는 어떤 형태로 사용하든 올바르게 동작한다.

Event modifiers

DOM event handler는 동작을 변경하는 _modifier_를 가질 수 있다.
예를 들어 once 수정자가 있는 handler는 한 번만 실행된다.

<script>
    function handleClick() {
        alert('no more alerts')
    }
</script>

<button on:click|once={handleClick}>
    Click me
</button>

modifier 전체 목록

  • preventDefault - handler를 실행하기 전에 event.preventDefault() 를 호출한다.
    예를 들어 client-side form handling에 유용하다.
  • stopPropagation - event가 다음 element에 도달하는 것을 방지하는 event.stopPropagation() 을 호출한다.
  • passive - touch/wheel event에 대한 scrolling performance 향상. (Svelte가 안전하게 추가할 수 있는 위치에 자동으로 추가됨)
  • nonpassive - 명시적으로 passive: false 설정
  • capture - bubbling 단계 대신 capture 단계에서 handler를 시작 (MDN 문서)
  • once - handler가 처음 실행된 후 제거됨
  • self - event.target이 element 대상인 경우만 handler가 trigger 됨.
  • trusted - event.isTrustedtrue 인 경우에만 handler가 trigger 됨.
    즉, event가 user action에 의해 trigger 된 경우

modifier는 함께 연결할 수 있다. (예: on:click|once|capture={...} )

Component events

component는 event를 전달할 수 있다.
그렇게 하려면 event dispatcher를 만들어야 한다.

App.svelte :

<script>
    import Inner from './Inner.svelte';

    function handleMessage(event) {
        alert(event.detail.text);
    }
</script>

<Inner on:message={handleMessage}/>

Inner.svelte :

<script>
    import { createEventDispatcher } from 'svelte';

    const dispatch = createEventDispatcher();

    function sayHello() {
        dispatch('message', {
            text: 'Hello!'
        });
    }
</script>

<button on:click={sayHello}>
    Click to say hello
</button>

createEventDispatcher 는 component가 처음 인스턴스화 될 때 호출되어야 한다.
예를 들어 setTimeout callback 내부에서 나중에 할 수 없다.
이것은 dispatch component instance에 연결된다.

App component는 on:message 지시문 덕분에 Inner component가 전달한 메시지를 listening 하고 있다.
이 지시문은 on: 다음에 dispatch 할 event name의 prefix가 붙은 attribute이다. (위의 경우 message )

이 attribute가 없으면 message는 계속 발송되지만 app은 반응하지 않는다.

event name은 다른 이름으로 변경할 수 있다.
예를 들어 자식 component에서 dispatch('message')dispatch('myevent') 로 변경하고 상위 component에서 attribute name을 on:message 에서 on:myevent 로 변경한다.

Event forwarding

DOM event와 달리 component event는 bubbling 되지 않는다.
일부 깊게 중첩된 component에서 event를 listen 하려면 중간 component가 event를 전달(forward) 해야 한다.

이 경우 이전 장과 코드에서 <Inner /> 를 포함하는 Outer.svelte component가 있다.
( App.svelte 안에 Outer 안에 Inner가 호출되는 구성)

Outer.sveltecreateEventDispatcher 를 추가하고 message event를 listen 한 후 해당 event를 위한 handler를 만든다.

App.svelte :

<script>
    import Outer from './Outer.svelte';

    function handleMessage(event) {
        alert(event.detail.text);
    }
</script>

<Outer on:message={handleMessage}/>

Outer.svelte :

<script>
    import Inner from './Inner.svelte';
    import { createEventDispatcher } from 'svelte';

    const dispatch = createEventDispatcher();

    function forward(event) {
        dispatch('message', event.detail);
    }
</script>

<Inner on:message={forward}/>

Inner.svelte :

<script>
    import { createEventDispatcher } from 'svelte';

    const dispatch = createEventDispatcher();

    function sayHello() {
        dispatch('message', {
            text: 'Hello!'
        });
    }
</script>

<button on:click={sayHello}>
    Click to say hello
</button>

하지만 이는 작성해야 할 코드가 많기 때문에 Svelte는 이에 상응하는 shorthand를 제공한다.
값이 없는 on:message event 지시문은 모든 message event를 forward 한다.

<script>
    import Inner from './Inner.svelte';
</script>

<Inner on:message/>

DOM event forwarding

Event forwarding은 DOM event에서도 동작한다.

<CustomButton> 의 click에 대한 알림을 받길 원한다.
그렇게 하려면 CustomButton.svelte 안의 <button> element의 click event를 forward 하면 된다.

App.svelte :

<script>
    import CustomButton from './CustomButton.svelte';

    function handleClick() {
        alert('Button Clicked');
    }
</script>

<CustomButton on:click={handleClick}/>

CustomButton.svelte :

<button on:click>
    Click me
</button>

Bindings

Text inputs

일반적으로 Svelte의 data flow는 top down이다.
상위 component는 하위 component에 props를 설정할 수 있고 component는 element에 attribute를 설정할 수 있지만 그 반대로는 할 수 없다.

때때로 그 규칙을 깨는 게 유용할 수 있다.
<input> element의 경우를 예를 들어본다.
name 의 값을 event.target.value 로 설정하는 on:input event handler를 추가할 수 있지만 다른 form element와 함께하면 복잡해진다.

대신 다음 bind:value 지시문을 사용할 수 있다.

<script>
    let name = 'world';
</script>

<input bind:value={name}>

<h1>Hello {name}!</h1>

즉, name 값을 변경하면 input value가 update 될 뿐만 아니라 input value를 변경하면 name 도 update 된다.

Numeric inputs

DOM에서 모든 것은 string이다.
type="number"type="range" 같은 numeric input을 다룰 때는 input.value 를 사용하기 전에 강제해야 한다는 것을 의미하기 때문에 도움이 되지 않는다.

bind:value 를 사용하면 Svelte가 관리한다.

<script>
    let a = 1;
    let b = 2;
</script>

<label>
    <input type=number value={a} min=0 max=10>
    <input type=range value={a} min=0 max=10>
</label>

<label>
    <input type=number value={b} min=0 max=10>
    <input type=range value={b} min=0 max=10>
</label>

<p>{a} + {b} = {a + b}</p>

<style>
    label { display: flex }
    input, p { margin: 6px }
</style>

해당 예제를 bind:가 없는 경우와 있는 경우로 테스트해보면 bind: 설정이 있어야 같은 값을 사용하는 number input과 range input가 둘 중 하나의 값이 변경되었을 때 변경된 값이 서로 연동되는 것을 확인할 수 있다.

Checkbox inputs

Checkbox는 state를 toggle 하는 데 사용된다.

input.value 에 bind 하는 대신 input.checked 에 bind 된다.

<script>
    let yes = false;
</script>

<label>
    <input type=checkbox bind:checked={yes}>
    Yes! Send me regular email spam
</label>

{#if yes}
    <p>Thank you. We will bombard your inbox and sell your personal details.</p>
{:else}
    <p>You must opt in to continue. If you're not paying, you're the product.</p>
{/if}

<button disabled={!yes}>
    Subscribe
</button>

bind: 를 사용하면 check input이 변경되었을 때 if 조건문도 변경된다.

Group inputs

동일한 값과 관련된 input이 여러 개인 경우 value attribute과 함께 bind:group 을 사용할 수 있다.
동일한 그룹의 radio input은 상호 배타적이며 동일한 그룹의 checkbox input은 선택한 값의 배열을 형성한다.

각 input에 bind:group 을 추가하여 사용한다.

<input type=radio bind:group={scoops} name="scoops" value={1}>

checkbox input의 값 목록을 javasciprt array 변수를 사용하여 코드를 더 단순하게 만들 수 있다.
먼저 menu variable을 <script> block에 추가한다.

let menu = [
    'Cookies and cream',
    'Mint choc chip',
    'Raspberry ripple'
];

그리고 HTML에서 each 구문을 사용한다.

<h2>Flavours</h2>

{#each menu as flavour}
    <label>
        <input type=checkbox bind:group={flavours} name="flavours" value={flavour}>
        {flavour}
    </label>
{/each}

전체 코드는 다음과 같다.

<script>
    let scoops = 1;
    let flavours = ['Mint choc chip'];

    let menu = [
        'Cookies and cream',
        'Mint choc chip',
        'Raspberry ripple'
    ];

    function join(flavours) {
        if (flavours.length === 1) return flavours[0];
        return `${flavours.slice(0, -1).join(', ')} and ${flavours[flavours.length - 1]}`;
    }
</script>

<h2>Size</h2>

<label>
    <input type=radio bind:group={scoops} name="scoops" value={1}>
    One scoop
</label>

<label>
    <input type=radio bind:group={scoops} name="scoops" value={2}>
    Two scoops
</label>

<label>
    <input type=radio bind:group={scoops} name="scoops" value={3}>
    Three scoops
</label>

<h2>Flavours</h2>

{#each menu as flavour}
    <label>
        <input type=checkbox bind:group={flavours} name="flavours" value={flavour}>
        {flavour}
    </label>
{/each}

{#if flavours.length === 0}
    <p>Please select at least one flavour</p>
{:else if flavours.length > scoops}
    <p>Can't order more flavours than scoops!</p>
{:else}
    <p>
        You ordered {scoops} {scoops === 1 ? 'scoop' : 'scoops'}
        of {join(flavours)}
    </p>
{/if}

checkbox나 radio input의 선택한 결과가 if 문을 통해 text로도 연동된다.

Textarea inputs

<textarea> element는 text input과 유사하게 동작한다.
아래처럼 bind:value 를 사용한다.

<textarea bind:value={value}></textarea>

이름이 일치하는 경우 shorthand 형식으로 사용할 수 있다.

<textarea bind:value></textarea>

이런 shorthand 형식은 모든 binding에 적용된다.

Select bindings

bind:value<select> 와 함께 사용할 수도 있다.

<script>
    let questions = [
        { id: 1, text: `Where did you go to school?` },
        { id: 2, text: `What is your mother's name?` },
        { id: 3, text: `What is another personal fact that an attacker could easily find with Google?` }
    ];

    let selected;

    let answer = '';

    function handleSubmit() {
        alert(`answered question ${selected.id} (${selected.text}) with "${answer}"`);
    }
</script>

<h2>Insecurity questions</h2>

<form on:submit|preventDefault={handleSubmit}>
    <select bind:value={selected} on:change="{() => answer = ''}">
        {#each questions as question}
            <option value={question}>
                {question.text}
            </option>
        {/each}
    </select>

    <input bind:value={answer}>

    <button disabled={!answer} type=submit>
        Submit
    </button>
</form>

<p>selected question {selected ? selected.id : '[waiting...]'}</p>

<style>
    input {
        display: block;
        width: 500px;
        max-width: 100%;
    }
</style>

여기서 <option> 의 value는 string 대신 object를 사용하였다.
Svelte는 string이 아닌 object도 사용할 수 있다.

selected 의 초기값을 설정하지 않았으므로 binding에서 자동으로 기본값 (목록의 첫 번째 값)으로 설정한다.
그러나 binding이 초기화되기 전까지 selected 항목은 undefined 상태로 남아있으므로 template의 selected.id 와 같이 무작정 참조할 수 없다.
위 예에서 허용하는 경우 초기 값을 설정하여 이 문제를 무시할 수도 있다.

Select multiple

select는 multiple attribute를 가질 수 있다.
이 경우 단일 값을 선택하는 대신 array를 채운다.

이전 아이스크림의 예로 돌아가면 checkbox를 <select multiple> 로 바꿀 수 있다.

<script>
    let scoops = 1;
    let flavours = ['Mint choc chip'];

    let menu = [
        'Cookies and cream',
        'Mint choc chip',
        'Raspberry ripple'
    ];

    function join(flavours) {
        if (flavours.length === 1) return flavours[0];
        return `${flavours.slice(0, -1).join(', ')} and ${flavours[flavours.length - 1]}`;
    }
</script>

<h2>Size</h2>

<label>
    <input type=radio bind:group={scoops} value={1}>
    One scoop
</label>

<label>
    <input type=radio bind:group={scoops} value={2}>
    Two scoops
</label>

<label>
    <input type=radio bind:group={scoops} value={3}>
    Three scoops
</label>

<h2>Flavours</h2>

<select multiple bind:value={flavours}>
    {#each menu as flavour}
        <option value={flavour}>
            {flavour}
        </option>
    {/each}
</select>

{#if flavours.length === 0}
    <p>Please select at least one flavour</p>
{:else if flavours.length > scoops}
    <p>Can't order more flavours than scoops!</p>
{:else}
    <p>
        You ordered {scoops} {scoops === 1 ? 'scoop' : 'scoops'}
        of {join(flavours)}
    </p>
{/if}

multiple option을 선택하려면 control key (또는 MacOS의 경우 command key)를 누르고 있으면 된다.

Contenteditable bindings

contenteditable="true" attribute가 있는 element는 textContentinnerHTML 의 binding을 지원한다.

<script>
    let html = '<p>Write some text!</p>';
</script>

<div
    contenteditable="true"
    bind:innerHTML={html}
></div>

<pre>{html}</pre>

<style>
    [contenteditable] {
        padding: 0.5em;
        border: 1px solid #eee;
        border-radius: 4px;
    }
</style>

Each block bindings

each block 내 properties도 binding 할 수 있다.

<script>
    let todos = [
        { done: false, text: 'finish Svelte tutorial' },
        { done: false, text: 'build an app' },
        { done: false, text: 'world domination' }
    ];

    function add() {
        todos = todos.concat({ done: false, text: '' });
    }

    function clear() {
        todos = todos.filter(t => !t.done);
    }

    $: remaining = todos.filter(t => !t.done).length;
</script>

<h1>Todos</h1>

{#each todos as todo}
    <div class:done={todo.done}>
        <input
            type=checkbox
            bind:checked={todo.done}
        >

        <input
            placeholder="What needs to be done?"
            bind:value={todo.text}
        >
    </div>
{/each}

<p>{remaining} remaining</p>

<button on:click={add}>
    Add new
</button>

<button on:click={clear}>
    Clear completed
</button>

<style>
    .done {
        opacity: 0.4;
    }
</style>

<input> element와 상호 작용하면 array가 변형된다. 불변 데이터로 작업하려면 이러한 binding을 피하고 대신 event handler를 사용해야 한다.

Media elements

<audio><video> element는 binding 할 수 있는 몇 가지 properties가 있다.
이 예에서는 이 중 몇 가지를 보여준다.

currentTime={time} , durationpaused binding을 추가하였다.

<script>
    // These values are bound to properties of the video
    let time = 0;
    let duration;
    let paused = true;

    let showControls = true;
    let showControlsTimeout;

    // Used to track time of last mouse down event
    let lastMouseDown;

    function handleMove(e) {
        // Make the controls visible, but fade out after
        // 2.5 seconds of inactivity
        clearTimeout(showControlsTimeout);
        showControlsTimeout = setTimeout(() => showControls = false, 2500);
        showControls = true;

        if (!duration) return; // video not loaded yet
        if (e.type !== 'touchmove' && !(e.buttons & 1)) return; // mouse not down

        const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
        const { left, right } = this.getBoundingClientRect();
        time = duration * (clientX - left) / (right - left);
    }

    // we can't rely on the built-in click event, because it fires
    // after a drag — we have to listen for clicks ourselves
    function handleMousedown(e) {
        lastMouseDown = new Date();
    }

    function handleMouseup(e) {
        if (new Date() - lastMouseDown < 300) {
            if (paused) e.target.play();
            else e.target.pause();
        }
    }

    function format(seconds) {
        if (isNaN(seconds)) return '...';

        const minutes = Math.floor(seconds / 60);
        seconds = Math.floor(seconds % 60);
        if (seconds < 10) seconds = '0' + seconds;

        return `${minutes}:${seconds}`;
    }
</script>

<h1>Caminandes: Llamigos</h1>
<p>From <a href="https://studio.blender.org/films">Blender Studio</a>. CC-BY</p>

<div>
    <video
        poster="https://sveltejs.github.io/assets/caminandes-llamigos.jpg"
        src="https://sveltejs.github.io/assets/caminandes-llamigos.mp4"
        on:mousemove={handleMove}
        on:touchmove|preventDefault={handleMove}
        on:mousedown={handleMousedown}
        on:mouseup={handleMouseup}
        bind:currentTime={time}
        bind:duration
        bind:paused>
        <track kind="captions">
    </video>

    <div class="controls" style="opacity: {duration && showControls ? 1 : 0}">
        <progress value="{(time / duration) || 0}"/>

        <div class="info">
            <span class="time">{format(time)}</span>
            <span>click anywhere to {paused ? 'play' : 'pause'} / drag to seek</span>
            <span class="time">{format(duration)}</span>
        </div>
    </div>
</div>

<style>
    div {
        position: relative;
    }

    .controls {
        position: absolute;
        top: 0;
        width: 100%;
        transition: opacity 1s;
    }

    .info {
        display: flex;
        width: 100%;
        justify-content: space-between;
    }

    span {
        padding: 0.2em 0.5em;
        color: white;
        text-shadow: 0 0 8px black;
        font-size: 1.4em;
        opacity: 0.7;
    }

    .time {
        width: 3em;
    }

    .time:last-child { text-align: right }

    progress {
        display: block;
        width: 100%;
        height: 10px;
        -webkit-appearance: none;
        appearance: none;
    }

    progress::-webkit-progress-bar {
        background-color: rgba(0,0,0,0.2);
    }

    progress::-webkit-progress-value {
        background-color: rgba(255,255,255,0.6);
    }

    video {
        width: 100%;
    }
</style>

bind:durationbind:duration={duration} 과 같다.

비디오를 클릭하면 time , durationpause 가 변경된다.
즉, 사용자 지정 컨트롤을 구축하는 데 사용할 수 있다.

일반적으로 웹에서는 timeupdate event를 수신하여 currentTime 을 추적한다.
그러나 이러한 이벤트는 너무 자주 발생하여 UI가 끊기는 결과를 초래한다.
Svelte는 requestAnimationFrame 을 사용하여 currentTime 을 확인한다.

및 에 대한 전체 binding set은 다음과 같다.
6개의 readonly binding

  • duration (readonly) - video의 총 지속 시간 (초)
  • buffered (readonly) - {start, end} object array
  • seekable (readonly) - 위와 같음
  • played (readonly) - 위와 같음
  • seeking (readonly) - boolean
  • ended (readonly) - boolean

5개의 양방향(two-way) binding

  • currentTime - video의 현재 지점
  • playbackRate - video를 재생하는 속도, 1 은 정상 속도
  • paused - 정지
  • volume - 0과 1 사이의 값
  • muted - true가 음소거인 boolean 값

video에는 추가로 readonly videoWidthvideoHeight binding이 있다.

Dimensions

모든 block-level element는 clientWidth , clientHeight , offsetWidthoffsetHeight binding이 있다.

<script>
    let w;
    let h;
    let size = 42;
    let text = 'edit me';
</script>

<input type=range bind:value={size}>
<input bind:value={text}>

<p>size: {w}px x {h}px</p>

<div bind:clientWidth={w} bind:clientHeight={h}>
    <span style="font-size: {size}px">{text}</span>
</div>

<style>
    input { display: block; }
    div { display: inline-block; }
    span { word-break: break-all; }
</style>

이러한 binding은 읽기 전용이다. - w , h 를 변경해도 아무 효과가 없다.

element는 이와 유사한 기술을 사용하여 측정된다.
약간의 오버헤드가 수반되므로 많은 수의 element에 사용하지 않는 것이 좋다.

이 접근 방식으로는 display: inline element를 측정할 수 없다.
다른 element를 포함할 수 없는 요소 (예, <canvas> )도 마찬가지이다.
이러한 경우 대신 wrapper element를 측정해야 한다.

This

읽기 전용 this binding은 모든 element (및 component)에 적용되며 렌더링 된 element에 대한 참조를 얻을 수 있다.
예를 들어 <canbas> element에 대한 참조를 얻을 수 있다.

<script>
    import { onMount } from 'svelte';

    let canvas;

    onMount(() => {
        const ctx = canvas.getContext('2d');
        let frame = requestAnimationFrame(loop);

        function loop(t) {
            frame = requestAnimationFrame(loop);

            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

            for (let p = 0; p < imageData.data.length; p += 4) {
                const i = p / 4;
                const x = i % canvas.width;
                const y = i / canvas.width >>> 0;

                const r = 64 + (128 * x / canvas.width) + (64 * Math.sin(t / 1000));
                const g = 64 + (128 * y / canvas.height) + (64 * Math.cos(t / 1000));
                const b = 128;

                imageData.data[p + 0] = r;
                imageData.data[p + 1] = g;
                imageData.data[p + 2] = b;
                imageData.data[p + 3] = 255;
            }

            ctx.putImageData(imageData, 0, 0);
        }

        return () => {
            cancelAnimationFrame(frame);
        };
    });
</script>

<canvas
    bind:this={canvas}
    width={32}
    height={32}
></canvas>

<style>
    canvas {
        width: 100%;
        height: 100%;
        background-color: #666;
        -webkit-mask: url(/svelte-logo-mask.svg) 50% 50% no-repeat;
        mask: url(/svelte-logo-mask.svg) 50% 50% no-repeat;
    }
</style>

위 코드의 경우 bind:this={canvas}로 canvas 값은 component가 mount 될 때까지 undefined 상태이므로 logic을 onMount lifecycle function 내부에 넣는다.

Component bindings

DOM element의 properties에 binding 할 수 있는 것처럼 component의 props에 binding 할 수 있다.
예를 들어 이 <Keypad> component의 value prop에 form element인 것처럼 binding 할 수 있다.

App.svelte :

<script>
    import Keypad from './Keypad.svelte';

    let pin;
    $: view = pin ? pin.replace(/\d(?!$)/g, '•') : 'enter your pin';

    function handleSubmit() {
        alert(`submitted ${pin}`);
    }
</script>

<h1 style="color: {pin ? '#333' : '#ccc'}">{view}</h1>

<Keypad bind:value={pin} on:submit={handleSubmit}/>

Keypad.svelte :

<script>
    import { createEventDispatcher } from 'svelte';

    export let value = '';

    const dispatch = createEventDispatcher();

    const select = num => () => value += num;
    const clear  = () => value = '';
    const submit = () => dispatch('submit');
</script>

<div class="keypad">
    <button on:click={select(1)}>1</button>
    <button on:click={select(2)}>2</button>
    <button on:click={select(3)}>3</button>
    <button on:click={select(4)}>4</button>
    <button on:click={select(5)}>5</button>
    <button on:click={select(6)}>6</button>
    <button on:click={select(7)}>7</button>
    <button on:click={select(8)}>8</button>
    <button on:click={select(9)}>9</button>

    <button disabled={!value} on:click={clear}>clear</button>
    <button on:click={select(0)}>0</button>
    <button disabled={!value} on:click={submit}>submit</button>
</div>

<style>
    .keypad {
        display: grid;
        grid-template-columns: repeat(3, 5em);
        grid-template-rows: repeat(4, 3em);
        grid-gap: 0.5em
    }

    button {
        margin: 0
    }
</style>

이제 사용자가 keypad와 상호 작용할 때 상위 component의 pin 값이 즉시 업데이트된다.

component binding을 적게 사용합니다.
특히 단일 정보 출처가 없는 경우 application 주변의 데이터 흐름을 추적하기 어려울 수 있다.

Binding to component instances

DOM element에 binding 할 수 있는 것처럼 component instance 자체에 binding 할 수 있다.
예를 들어 DOM element를 binding 할 때와 같은 방법으로 <InputField> 의 instance를 field 로 이름이 지정된 변수에 binding 할 수 있다.

App.svelte :

<script>
    import InputField from './InputField.svelte';

    let field;
</script>

<InputField bind:this={field}/>

<button on:click={() => field.focus()}>Focus field</button>

InputField.svelte :

<script>
    let input;

    export function focus() {
        input.focus();
    }
</script>

<input bind:this={input} />

button이 렌더링 되고 오류가 발생하면 field가 정의되지 않으므로 {field.focus} 를 수행할 수 없다.

Lifecycle

onMount

모든 component는 생성될 때 시작되고 소멸될 때 끝나는 lifecycle이 있다.
해당 lifecycle 동안 중요한 순간에 코드를 실행할 수 있는 몇 가지 function이 있다.

가장 자주 사용되는 것은 component가 DOM에 처음 렌더링 된 후 실행되는 onMount 이다.
렌더링 후 <canvas> 요소와 상호 작용해야 할 때 이전에 잠깐 접했었다.

<script>
    import { onMount } from 'svelte';

    let photos = [];

    onMount(async () => {
        const res = await fetch(`/tutorial/api/album`);
        photos = await res.json();
    });
</script>

<h1>Photo album</h1>

<div class="photos">
    {#each photos as photo}
        <figure>
            <img src={photo.thumbnailUrl} alt={photo.title}>
            <figcaption>{photo.title}</figcaption>
        </figure>
    {:else}
        <!-- this block renders when photos.length === 0 -->
        <p>loading...</p>
    {/each}
</div>

<style>
    .photos {
        width: 100%;
        display: grid;
        grid-template-columns: repeat(5, 1fr);
        grid-gap: 8px;
    }

    figure, img {
        width: 100%;
        margin: 0;
    }
</style>

서버 측 렌더링(SSR) 때문에 <script> 의 최상위 수준이 아닌 onMountfetch 를 넣는 것이 좋다.
onDestory 를 제외하고 SSR 동안에는 lifecycle function이 실행되지 않으므로 component가 DOM에 mount 된 후에는 느리게 로드되어야 하는 데이터를 가져올 수 없다.

component를 초기화하는 동안 lifecycle function을 호출하여 callback이 component instance에 바인딩되도록 해야 한다. - setTimeout 을 말하는 게 아님

onMount callback이 function을 반환하면 component가 destory 될 때 해당 function이 호출된다.

onDestroy

component가 destroy 되었을 때 코드를 실행하려면 onDestroy 를 사용한다.

예를 들어 component가 초기화될 때 setInterval function을 추가하고 더 이상 관련이 없을 때 정리할 수 있다.
이렇게 하면 메모리 누출을 방지할 수 있다.

App.svelte :

<script>
    import Timer from './Timer.svelte';

    let open = true;
    let seconds = 0;

    const toggle = () => (open = !open);
    const handleTick = () => (seconds += 1);
</script>

<div>
    <button on:click={toggle}>{open ? 'Close' : 'Open'} Timer</button>
    <p>
        The Timer component has been open for
        {seconds} {seconds === 1 ? 'second' : 'seconds'}
    </p>
    {#if open}
    <Timer callback={handleTick} />
    {/if}
</div>

Timer.svelte :

<script>
    import { onInterval } from './utils.js';

    export let callback;
    export let interval = 1000;

    onInterval(callback, interval);
</script>

<p>
    This component executes a callback every 
    {interval} millisecond{interval === 1 ? '' : 's'}
</p>

<style>
    p { 
        border: 1px solid blue;
        padding: 5px;
    }
</style>

utils.js :

import { onDestroy } from 'svelte';

export function onInterval(callback, milliseconds) {
    const interval = setInterval(callback, milliseconds);

    onDestroy(() => {
        clearInterval(interval);
    });
}

component를 초기화하는 동안 lifecycle function을 호출하는 것은 중요하지만 어디에서 호출하는지는 중요하지 않다.
그래서 원한다면 interval logic을 helper function으로 추상화할 수 있다.

utils.js에 clearInterval이 없다면 interval이 초기화되지 않고 버튼을 눌러 Timer component가 생성될 때마다 setInterval이 호출되어 1초 주기로 증가하는 카운트가 버튼을 누른 만큼 증가하는 것을 볼 수 있다.
즉, 계속 버튼을 눌러 Timer component를 삭제했다 생성할수록 setInterval 선언이 쌓여가는 메모리 누수가 발생한다.

clearInterval을 선언한 상태에선 버튼을 눌러 Timer component가 destory 될 때마다 선언된 clearInterval이 호출되어 증가가 멈춘 상태가 되고 다시 버튼을 눌러 Timer component가 생성되면 다시 이어서 초가 증가되는 것을 확인할 수 있다.

beforeUpdate and afterUpdate

beforeUpdate function은 DOM이 업데이트되기 직전에 실행된다.
afterUpdate 는 DOM이 data와 동기화될 때 코드를 실행하는 데 사용된다.

함께 사용하면 element의 scroll 위치 업데이트와 같이 순수한 상태 기반 방식으로 처리하기 어려운 작업을 필수적으로 수행하는데 유용하다.

Eliza chatbot 예시는 채팅을 입력하면서 내가 입력한 대화와 챗봇의 답변이 채팅 화면에 갱신되는데 beforeUpdate와 afterUpdate를 이용해 채팅 화면이 스크롤을 가장 아래로 옮기는 처리를 한다.

예제 코드가 길기 때문에 전체 코드는 위 챕터 제목 항목에 걸린 링크를 통해 참고하고 스크롤 처리를 하는 코드만 여기에 적어둔다.

let div;
let autoscroll;

beforeUpdate(() => {
    autoscroll = div && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20);
});

afterUpdate(() => {
    if (autoscroll) div.scrollTo(0, div.scrollHeight);
});

beforeUpdate 가 component가 mount 되기 전에 먼저 실행되므로 div properties를 읽기 전에 존재하는지 여부를 확인해야 한다.

tick

tick function은 다른 lifecycle function과 다르게 component가 처음 초기화될 때뿐만 아니라 언제든지 호출할 수 있다.
pending stage 변경이 DOM에 적용되는 즉시 (또는 pending state 변경이 없는 경우 즉시) 해결되는 promise를 반환한다.

Svelte에서 component를 업데이트할 때 DOM은 즉시 업데이트되지 않는다.
대신 다른 component를 포함하여 적용해야 하는 다른 변경사항이 있는지 확인하기 위해 다음 microtask까지 대기한다.
그렇게 하면 불필요한 작업을 피할 수 있고 브라우저가 더 효과적으로 작업을 일괄 처리할 수 있다.

아래 예에서 해당 동작을 확인할 수 있다.
textarea의 text 중 일부를 드래그하여 범위 선택하고 탭 키를 누르면 대문자로 변경된다.
만약 await tick() 선언 부분이 없다면 <textarea> 값이 변경되기 때문에 현재 범위 선택도 지워지고 커서가 맨 끝으로 이동한다.

<script>
    import { tick } from 'svelte';

    let text = `Select some text and hit the tab key to toggle uppercase`;

    async function handleKeydown(event) {
        if (event.key !== 'Tab') return;

        event.preventDefault();

        const { selectionStart, selectionEnd, value } = this;
        const selection = value.slice(selectionStart, selectionEnd);

        const replacement = /[a-z]/.test(selection)
            ? selection.toUpperCase()
            : selection.toLowerCase();

        text = (
            value.slice(0, selectionStart) +
            replacement +
            value.slice(selectionEnd)
        );

        await tick();
        this.selectionStart = selectionStart;
        this.selectionEnd = selectionEnd;
    }
</script>

<style>
    textarea {
        width: 100%;
        height: 200px;
    }
</style>

<textarea value={text} on:keydown={handleKeydown}></textarea>

Stores

Writable stores

모든 application state가 application의 component hierarchy에 속하는 것은 아니다.
때로는 관련되지 않은 여러 component 또는 일반 JavaScript module에서 access 해야 하는 값이 있다.

Svelte에서 이는 store에서 한다.
store는 단순히 store의 값이 변경될 때마다 관심 있는 파티들에 알릴 수 있는 subscribe method를 가진 간단한 object이다.
App.svelte 에서 count 는 store이고 count.subscribe callback에서 conutValue 를 설정하고 있다.

예제를 보자.

App.svelte :

<script>
    import { count } from './stores.js';
    import Incrementer from './Incrementer.svelte';
    import Decrementer from './Decrementer.svelte';
    import Resetter from './Resetter.svelte';

    let countValue;

    count.subscribe(value => {
        countValue = value;
    });
</script>

<h1>The count is {countValue}</h1>

<Incrementer/>
<Decrementer/>
<Resetter/>

Decrementer.svelte :

<script>
    import { count } from './stores.js';

    function decrement() {
        count.update(n => n - 1);
    }
</script>

<button on:click={decrement}>
    -
</button>

Incrementer.svelte :

<script>
    import { count } from './stores.js';

    function increment() {
        count.update(n => n + 1);
    }
</script>

<button on:click={increment}>
    +
</button>

Resetter.svelte :

<script>
    import { count } from './stores.js';

    function reset() {
        count.set(0);
    }
</script>

<button on:click={reset}>
    reset
</button>

store.js :

import { writable } from 'svelte/store';

export const count = writable(0);

예제를 보면 App.svelte에서 <Incrementer /> , <Decrementer /> , <Resetter /> 를 자식 component로 가지고 있고 각각 store 값을 변경을 처리한다.
이전에 사용한 props를 통한 값 전달 없이 store 값에 대한 처리를 하면 해당 값이 사용하고 있는 각 component에 공유되어 갱신된다.

Auto-subscriptions

이전 예제의 app은 동작하지만 미묘한 버그가 있다.
store에는 subscribe 하지만 unsubscribe를 하지 않았다.
component가 여러 번 instance화 되고 detroy 된 경우 메모리 누수가 발생한다.

따라서 App.svelteunsubscribe 를 선언하는 것부터 시작한다.

const unsubscribe = count.subscribe(value => {
    countValue = value;
});

subscribe method를 호출하면 unsubscribe function이 반환된다.

이제 unsubscribe 를 선언했지만 예를 들어 onDestroy lifecycle hook을 통해 계속 호출해야 한다.

<script>
    import { onDestroy } from 'svelte';
    import { count } from './stores.js';
    import Incrementer from './Incrementer.svelte';
    import Decrementer from './Decrementer.svelte';
    import Resetter from './Resetter.svelte';

    let countValue;

    const unsubscribe = count.subscribe(value => {
        countValue = value;
    });

    onDestroy(unsubscribe);
</script>

<h1>The count is {countValue}</h1>

그러나 component가 여러 store에 subscribe 한 경우 특히 약간 복잡해지기 시작한다.
대신 svelte에는 trick이 있다.
$ 를 앞에 붙이면 store값을 참조할 수 있다.
따라서 이제까지 설명한 것처럼 subscribe, unsubscribe를 사용하지 않고 아래처럼 간단히 {$count} 를 사용하면 된다.

<script>
    import { count } from './stores.js';
    import Incrementer from './Incrementer.svelte';
    import Decrementer from './Decrementer.svelte';
    import Resetter from './Resetter.svelte';
</script>

<h1>The count is {$count}</h1>

auto-subscription은 component의 top-level에서 선언 (또는 import)되는 store variable에서만 작동한다.

$count 는 markup 내에서만 사용할 수 있는 것이 아니라 eventHandler나 reactive declaration과 같은 <script> 의 어느 곳에서 나 사용할 수 있다.

$로 시작하는 모든 이름은 store value를 의미한다.
이는 예약된 문자이다.
Svelte는 $ prefix로 자신의 variable을 선언하지 못하게 한다.

Readable stores

모든 store를 참조하는 모두가 모든 store를 write 할 수 있는 것은 아니다.
예를 들어 mouse position 또는 사용자의 geolocation을 나타내는 store가 있을 수 있고 외부에서 값을 가져오는 경우 설정할 수 있는 것은 의미가 없다.
이런 경우를 위해 readable store가 있다.

예제를 보자.

App.svelte

<script>
    import { time } from './stores.js';

    const formatter = new Intl.DateTimeFormat('en', {
        hour12: true,
        hour: 'numeric',
        minute: '2-digit',
        second: '2-digit'
    });
</script>

<h1>The time is {formatter.format($time)}</h1>

store.js

import { readable } from 'svelte/store';

export const time = readable(new Date(), function start(set) {
    const interval = setInterval(() => {
        set(new Date());
    }, 1000);

    return function stop() {
        clearInterval(interval);
    };
});

시간을 표시하는 예제이고 시간은 store에서 time 변수를 readable method로 선언하였다.
첫 번째 argument new Date()는 초기 값이며, 값이 아직 없으면 null 이거나 undefined 일 수 있다.
두 번째 argument는 설정된 callback을 받고 stop function을 반환하는 start funcion이다.
첫 번째 subscriber를 받으면 start function이 호출되고 마지막 subscriber가 unsubscribe 하면 stop이 호출된다.

Derived stores

derived 를 사용하여 하나 이상의 다른 store의 값을 기반으로 하는 store를 생성할 수 있다.
이전 예제에서 페이지를 연 시간을 derive 하는 store를 만들 수 있다.

예제를 보자.

App.svelte :

<script>
    import { time, elapsed } from './stores.js';

    const formatter = new Intl.DateTimeFormat('en', {
        hour12: true,
        hour: 'numeric',
        minute: '2-digit',
        second: '2-digit'
    });
</script>

<h1>The time is {formatter.format($time)}</h1>

<p>
    This page has been open for
    {$elapsed} {$elapsed === 1 ? 'second' : 'seconds'}
</p>

store.js :

import { readable, derived } from 'svelte/store';

export const time = readable(new Date(), function start(set) {
    const interval = setInterval(() => {
        set(new Date());
    }, 1000);

    return function stop() {
        clearInterval(interval);
    };
});

const start = new Date();

export const elapsed = derived(
    time,
    $time => Math.round(($time - start) / 1000)
);

multiple input에서 store를 derive 하고 set 값을 반환하는 대신 명시적으로 값을 derive 하는 것이 가능하다.
(비동기적으로 값을 derive 하는데 유용함.) 자세한 내용은 API reference를 참조하면 된다.

Custom stores

object가 subscribe method를 올바르게 구현하기만 하면 그것은 store이다.
그 이상은 무엇이든 할 수 있다.
따라서 domain 별 logic으로 custom store를 만드는 것은 매우 쉽다.

예를 들어 이전에 봤던 count store 예제에는 increment , decrementreset method가 포함될 수 있으며 setupdate 의 노출을 피할 수 있다.

App.svelte :

<script>
    import { count } from './stores.js';
</script>

<h1>The count is {$count}</h1>

<button on:click={count.increment}>+</button>
<button on:click={count.decrement}>-</button>
<button on:click={count.reset}>reset</button>

store.js :

import { writable } from 'svelte/store';

function createCount() {
    const { subscribe, set, update } = writable(0);

    return {
        subscribe,
        increment: () => update(n => n + 1),
        decrement: () => update(n => n - 1),
        reset: () => set(0)
    };
}

export const count = createCount();

Store bindings

store가 writable 이면 (즉, set method가 있는 경우) local component state에 binding 할 수 있는 것처럼 해당 값에 binding 할 수 있다.

예제를 보자.

App.svelte

<script>
    import { name, greeting } from './stores.js';
</script>

<h1>{$greeting}</h1>
<input bind:value={$name}>

<button on:click="{() => $name += '!'}">
    Add exclamation mark!
</button>

store.js

import { writable, derived } from 'svelte/store';

export const name = writable('world');

export const greeting = derived(
    name,
    $name => `Hello ${$name}!`
);

이 예제에는 writable store name 과 derive store greeting 이 있다.
<input> element 값을 변경하면 이제 name과 모든 종속 항목이 업데이트된다.

! 를 추가하는 버튼(Add exclamation mark! button)을 클릭하거나 input에 직접 변경하여 확인할 수 있다.

$name += '!' 구문은 name.set($name + '!') 구문과 동일하다.

Motion

Tweened

value를 설정하고 DOM update가 자동으로 변경되는 것은 멋진 일이다.
이보다 더 좋은 것은 이러한 value를 Tweening 하는 것이다.
Svelte에는 animation을 사용하여 변경 사항을 전달하는 향상된 user interface를 구축하는데 도움이 되는 tool이 포함되어 있다.

다음의 예제를 보자.

<script>
    import { tweened } from 'svelte/motion';
    import { cubicOut } from 'svelte/easing';

    const progress = tweened(0, {
        duration: 400,
        easing: cubicOut
    });
</script>

<progress value={$progress}></progress>

<button on:click="{() => progress.set(0)}">
    0%
</button>

<button on:click="{() => progress.set(0.25)}">
    25%
</button>

<button on:click="{() => progress.set(0.5)}">
    50%
</button>

<button on:click="{() => progress.set(0.75)}">
    75%
</button>

<button on:click="{() => progress.set(1)}">
    100%
</button>

<style>
    progress {
        display: block;
        width: 100%;
    }
</style>

svelte/easing 모듈에는 Penner easing equation (penner 완화 방정식?)이 포함되어 있거나 pt 가 모두 0과 1 사이의 값인 자체 p => t 함수를 제공할 수 있다.

tweened 의 사용할 수 있는 모든 옵션은 다음과 같다.

  • delay - tween 시작 전 millisecond
  • duration - tween의 지속 시간(millisecond) 또는 value의 더 큰 변화에 대해 더 긴 tween을 지정할 수 있는 (from, to) => milliseconds function
  • easing - p => t function
  • interpolate - 임의의 값 사이를 보간하기 위한 custom (from, to) => t => value function
    기본적으로 svelte는 숫자, 날짜, 동일한 모양의 array와 object 사이를 보간한다.
    (숫자와 날짜 또는 기타 유요한 array와 object를 포함하는 한)
    예를 들어 색상 문자열이나 변환 행렬을 보간하려면 custom interpolator를 제공한다.

progress.setprogress.update option을 두 번째 argument로 전달할 수도 있다.
이 경우 기본 값을 override 한다.
setupdate method는 모두 tween이 완료될 때 해결되는 promise를 반환한다.

Spring

spring function은 자주 변경되는 값에 대해 더 잘 작동하는 tweened 에 대한 대안이다.

이 예제에는 두 개의 store가 있다.
하나는 원의 좌표를 나타내고 다른 하나는 크기를 나타낸다.

<script>
    import { spring } from 'svelte/motion';

    let coords = spring({ x: 50, y: 50 }, {
        stiffness: 0.1,
        damping: 0.25
    });

    let size = spring(10);
</script>

<div style="position: absolute; right: 1em;">
    <label>
        <h3>stiffness ({coords.stiffness})</h3>
        <input bind:value={coords.stiffness} type="range" min="0" max="1" step="0.01">
    </label>

    <label>
        <h3>damping ({coords.damping})</h3>
        <input bind:value={coords.damping} type="range" min="0" max="1" step="0.01">
    </label>
</div>

<svg
    on:mousemove="{e => coords.set({ x: e.clientX, y: e.clientY })}"
    on:mousedown="{() => size.set(30)}"
    on:mouseup="{() => size.set(10)}"
>
    <circle cx={$coords.x} cy={$coords.y} r={$size}/>
</svg>

<style>
    svg {
        width: 100%;
        height: 100%;
    }
    circle {
        fill: #ff3e00;
    }
</style>

마우스가 움직일 때 원의 좌표도 변경이 되고 클릭 시 사이즈가 커지고 클릭을 놓으면 다시 사이즈가 줄어든다.
이 과정에서 stiffnessdamping 이 변경에 대한 tweening을 한다.

자세한 내용은 API reference를 참조하면 된다.

Transitions

The transition directive

DOM 안팎으로 element를 우아하게 전환하여 보다 멋진 user interface를 만들 수 있다.
svelte는 transition 지시문으로 이것을 매우 쉽게 만든다.

<script>
    import { fade } from 'svelte/transition';
    let visible = true;
</script>

<label>
    <input type="checkbox" bind:checked={visible}>
    visible
</label>

{#if visible}
    <p transition:fade>
        Fades in and out
    </p>
{/if}

Adding parameters

transition function은 parameter를 받을 수 있다.

<script>
    import { fly } from 'svelte/transition';
    let visible = true;
</script>

<label>
    <input type="checkbox" bind:checked={visible}>
    visible
</label>

{#if visible}
    <p transition:fly="{{ y: 200, duration: 2000 }}">
        Flies in and out
    </p>
{/if}

위 설정은 fade in out시 y 축으로 2초 동안 이동하여 위치하는 효과를 보여준다.

또한 transiction은 되돌릴 수 있다.(reversible) - transition이 진행 중인 동안 다시 체크 박스를 선택해 변경하면 현재 상태에서 부드럽게 다시 되돌아간다.

In and out

transition 지시문 대신 element는 in 또는 out 지시문이 있거나 둘 다 있을 수 있다.
flay와 fade를 함께 import 한다.

<script>
    import { fade, fly } from 'svelte/transition';
    let visible = true;
</script>

<label>
    <input type="checkbox" bind:checked={visible}>
    visible
</label>

{#if visible}
    <p in:fly="{{ y: 200, duration: 2000 }}" out:fade>
        Flies in, fades out
    </p>
{/if}

이 예제에서 in 효과는 fly를, out 효과는 fade를 사용한다.

다만 이 경우엔 반전되지 않는다. (not reversed)

Custom CSS transitions

svelte/transition module에는 몇 가지 내장 transition이 있지만 자신만의 transition을 만드는 것은 매우 쉽다.
예를 들어 다음과 같이 fade transition을 만들 수 있다.

<script>
    import { fade } from 'svelte/transition';
    import { elasticOut } from 'svelte/easing';

    let visible = true;

    function spin(node, { duration }) {
        return {
            duration,
            css: t => {
                const eased = elasticOut(t);

                return `
                    transform: scale(${eased}) rotate(${eased * 1080}deg);
                    color: hsl(
                        ${Math.trunc(t * 360)},
                        ${Math.min(100, 1000 - 1000 * t)}%,
                        ${Math.min(50, 500 - 500 * t)}%
                    );`
            }
        };
    }
</script>

<label>
    <input type="checkbox" bind:checked={visible}>
    visible
</label>

{#if visible}
    <div class="centered" in:spin="{{duration: 8000}}" out:fade>
        <span>transitions!</span>
    </div>
{/if}

<style>
    .centered {
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%,-50%);
    }

    span {
        position: absolute;
        transform: translate(-50%,-50%);
        font-size: 4em;
    }
</style>

funciton은 2개의 argument(transition이 적용되는 node 및 전달될 parameter)를 사용하고 다음 properties를 가질 수 있는 transition object를 반환한다.

  • delay - transition이 시작되기 전 millisecond
  • duration - millisecond 단위의 transition 길이
  • easing - p => t easing function (tweening chapter 참고)
  • css - (t, u) => css function, 여기서 u === 1 - t
  • tick - (t, u) => {...} function (node에 영향을 주는 function)

t 값은 intro 또는 outro의 끝에서 0 이고 시작에서 1 이다.

가능한 경우 버벅거림을 방지하기 위해 css animation이 main thread에서 실행되므로 대부분의 경우 tick property가 아닌 css property를 반환해야 한다.
svelte는 transition을 'simulation'하고 css animation을 구성한 다음 실행시킨다.

예를 들어 fade transition은 다음과 같은 css animation을 생성한다.

0% { opacity: 0 }
10% { opacity: 0.1 }
20% { opacity: 0.2 }
/* ... */
100% { opacity: 1 }

Custom JS transitions

일반적으로 가능한 한 많은 transition에 css를 사용해야 하지만, typewriter effect 같이 javascript 없이는 만들 수 없는 effect도 있다.

<script>
    let visible = false;

    function typewriter(node, { speed = 1 }) {
        const valid = (
            node.childNodes.length === 1 &&
            node.childNodes[0].nodeType === Node.TEXT_NODE
        );

        if (!valid) {
            throw new Error(`This transition only works on elements with a single text node child`);
        }

        const text = node.textContent;
        const duration = text.length / (speed * 0.01);

        return {
            duration,
            tick: t => {
                const i = Math.trunc(text.length * t);
                node.textContent = text.slice(0, i);
            }
        };
    }
</script>

<label>
    <input type="checkbox" bind:checked={visible}>
    visible
</label>

{#if visible}
    <p transition:typewriter>
        The quick brown fox jumps over the lazy dog
    </p>
{/if}

Transition events

transition이 시작되고 끝나는 시점을 아는 것이 유용할 수 있다.
svelte는 다른 DOM event처럼 listen 할 수 있는 event를 전달한다.

<script>
    import { fly } from 'svelte/transition';

    let visible = true;
    let status = 'waiting...';
</script>

<p>status: {status}</p>

<label>
    <input type="checkbox" bind:checked={visible}>
    visible
</label>

{#if visible}
    <p
        transition:fly="{{ y: 200, duration: 2000 }}"
        on:introstart="{() => status = 'intro started'}"
        on:outrostart="{() => status = 'outro started'}"
        on:introend="{() => status = 'intro ended'}"
        on:outroend="{() => status = 'outro ended'}"
    >
        Flies in and out
    </p>
{/if}

위 예제를 실행해보면 in, out 각각의 event의 시작과 종료에 대해 감지하여 해당 상황을 text로 표시하고 있다.

Local transitions

일반적으로 container block이 추가되거나 제거되면 transition이 element에서 play 된다.
여기의 예에서 전체 list의 visibility를 토글 하면 개별 list element에도 transition이 적용된다.

<script>
    import { slide } from 'svelte/transition';

    let showItems = true;
    let i = 5;
    let items = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
</script>

<label>
    <input type="checkbox" bind:checked={showItems}>
    show list
</label>

<label>
    <input type="range" bind:value={i} max=10>

</label>

{#if showItems}
    {#each items.slice(0, i) as item}
        <div transition:slide>
            {item}
        </div>
    {/each}
{/if}

<style>
    div {
        padding: 0.5em 0;
        border-top: 1px solid #eee;
    }
</style>

대신 개별 item이 추가 및 제거될 때, 위의 경우 사용자가 slider를 끌 때만 play 되기를 원하면 transition 자체가 있는 block이 추가되거나 제거될 때만 play 되는 local transition으로 이를 처리할 수 있다.

<div transition:slide|local>
    {item}
</div>

이렇게 local이란 값만 추가하면 checkbox를 통해 visible이 변경될 때엔 slide가 적용되지 않는다.

Deferred transitions

defer transition으로 여러 element 사이에서 조정될 수 있다.

예제가 너무 길어서 style 태그는 생략하였다. svelte tutorial에서 전체 예제를 확인할 수 있다.

<script>
    import { quintOut } from 'svelte/easing';
    import { crossfade } from 'svelte/transition';

    const [send, receive] = crossfade({
        duration: d => Math.sqrt(d * 200),

        fallback(node, params) {
            const style = getComputedStyle(node);
            const transform = style.transform === 'none' ? '' : style.transform;

            return {
                duration: 600,
                easing: quintOut,
                css: t => `
                    transform: ${transform} scale(${t});
                    opacity: ${t}
                `
            };
        }
    });

    let uid = 1;

    let todos = [
        { id: uid++, done: false, description: 'write some docs' },
        { id: uid++, done: false, description: 'start writing blog post' },
        { id: uid++, done: true,  description: 'buy some milk' },
        { id: uid++, done: false, description: 'mow the lawn' },
        { id: uid++, done: false, description: 'feed the turtle' },
        { id: uid++, done: false, description: 'fix some bugs' },
    ];

    function add(input) {
        const todo = {
            id: uid++,
            done: false,
            description: input.value
        };

        todos = [todo, ...todos];
        input.value = '';
    }

    function remove(todo) {
        todos = todos.filter(t => t !== todo);
    }

    function mark(todo, done) {
        todo.done = done;
        remove(todo);
        todos = todos.concat(todo);
    }
</script>

<div class='board'>
    <input
        placeholder="what needs to be done?"
        on:keydown={e => e.key === 'Enter' && add(e.target)}
    >

    <div class='left'>
        <h2>todo</h2>
        {#each todos.filter(t => !t.done) as todo (todo.id)}
            <label
                in:receive="{{key: todo.id}}"
                out:send="{{key: todo.id}}"
            >
                <input type=checkbox on:change={() => mark(todo, true)}>
                {todo.description}
                <button on:click="{() => remove(todo)}">remove</button>
            </label>
        {/each}
    </div>

    <div class='right'>
        <h2>done</h2>
        {#each todos.filter(t => t.done) as todo (todo.id)}
            <label
                class="done"
                in:receive="{{key: todo.id}}"
                out:send="{{key: todo.id}}"
            >
                <input type=checkbox checked on:change={() => mark(todo, false)}>
                {todo.description}
                <button on:click="{() => remove(todo)}">remove</button>
            </label>
        {/each}
    </div>
</div>

예제에서 할 일을 토글 하면 done 목록으로 보낸다.
현실에서는 물체가 이동할 때 갑자기 위치가 변경되지 않는다.
motion을 사용하면 이동하는 과정을 보여주어 사용자가 app에서 일어나는 일을 이해하는데 도움이 될 수 있다.

receivesend 로 지정한 한쌍의 transition을 만드는 crossfade function을 사용하여 이러한 효과를 만들 수 있다.
element가 'send' 할 때 그것은 'receive' 될 해당 element를 찾고 element는 상대적인 position으로 변환하여 fadeout 시키는 transition을 생성한다.
element가 수신되면 그 반대가 발생한다.
상대방이 없는 경우 fallback transition이 사용된다.

65번 라인의 <label> 에 해당 transition이 다음과 같이 추가되었다.

<label
    in:receive="{{key: todo.id}}"
    out:send="{{key: todo.id}}"
>

다음

<label
    class="done"
    in:receive="{{key: todo.id}}"
    out:send="{{key: todo.id}}"
>

항목을 토글 하면 새 위치로 부드럽게 이동한다.
전화되지 않는 항목은 여전히 어색하게 이동하지만 이는 다음 장에서 수정할 수 있다.

Key blocks

key block은 표현식의 값이 변경될 때 내용을 destroy 하고 recreate 한다.

<script>
    import { fly } from 'svelte/transition';

    let number = 0;
</script>

<div>
    The number is:
    {#key number}
        <span style="display: inline-block" in:fly={{ y: -20 }}>
            {number}
        </span>
    {/key}
</div>
<br />
<button
    on:click={() => {
        number += 1;
    }}>
    Increment
</button>

이는 element가 DOM에 들어오거나 나갈 때만 아니라 값이 변경될 때마다 element가 transition을 play 하도록 하려는 경우 유용하다.

number 에 따라 key block에 <spen> element를 wrapping 한다.
이렇게 하면 increment button을 누를 때마다 animation이 play 된다.

Animations

The animate directive

이전 장에서 element가 하나의 할 일 목록에서 다른 목록으로 이동할 때 motion의 이동 효과를 만들기 위해 deferred transition을 사용했다.

이동 효과를 완성하려면 transition 되지 않는 요소에도 motion을 전환해야 한다.
이를 위해 animate 지시문을 사용한다.

예제가 너무 길어서 style 태그는 생략하였다. svelte tutorial에서 전체 예제를 확인할 수 있다.

<script>
    import { quintOut } from 'svelte/easing';
    import { crossfade } from 'svelte/transition';
    import { flip } from 'svelte/animate';

    const [send, receive] = crossfade({
        duration: d => Math.sqrt(d * 200),

        fallback(node, params) {
            const style = getComputedStyle(node);
            const transform = style.transform === 'none' ? '' : style.transform;

            return {
                duration: 600,
                easing: quintOut,
                css: t => `
                    transform: ${transform} scale(${t});
                    opacity: ${t}
                `
            };
        }
    });

    let uid = 1;

    let todos = [
        { id: uid++, done: false, description: 'write some docs' },
        { id: uid++, done: false, description: 'start writing blog post' },
        { id: uid++, done: true,  description: 'buy some milk' },
        { id: uid++, done: false, description: 'mow the lawn' },
        { id: uid++, done: false, description: 'feed the turtle' },
        { id: uid++, done: false, description: 'fix some bugs' },
    ];

    function add(input) {
        const todo = {
            id: uid++,
            done: false,
            description: input.value
        };

        todos = [todo, ...todos];
        input.value = '';
    }

    function remove(todo) {
        todos = todos.filter(t => t !== todo);
    }

    function mark(todo, done) {
        todo.done = done;
        remove(todo);
        todos = todos.concat(todo);
    }
</script>

<div class='board'>
    <input
        placeholder="what needs to be done?"
        on:keydown={e => e.key === 'Enter' && add(e.target)}
    >

    <div class='left'>
        <h2>todo</h2>
        {#each todos.filter(t => !t.done) as todo (todo.id)}
            <label
                in:receive="{{key: todo.id}}"
                out:send="{{key: todo.id}}"
                animate:flip
            >
                <input type=checkbox on:change={() => mark(todo, true)}>
                {todo.description}
                <button on:click="{() => remove(todo)}">remove</button>
            </label>
        {/each}
    </div>

    <div class='right'>
        <h2>done</h2>
        {#each todos.filter(t => t.done) as todo (todo.id)}
            <label
                class="done"
                in:receive="{{key: todo.id}}"
                out:send="{{key: todo.id}}"
                animate:flip
            >
                <input type=checkbox checked on:change={() => mark(todo, false)}>
                {todo.description}
                <button on:click="{() => remove(todo)}">remove</button>
            </label>
        {/each}
    </div>
</div>

먼저 svelte/animate 에서 flip function을 import 한다. - flip은 `First, Last, Invert, Play`를 의미한다.
그리고 <label> element에 flip을 추가한다.

움직임을 조절하고 싶다면 duration parameter를 추가할 수도 있다.

<label
    in:receive="{{key: todo.id}}"
    out:send="{{key: todo.id}}"
    animate:flip="{{duration: 200}}"
>

duration은 d => mlillisecond function 일 수 있다.
여기서 d 는 element가 이동해야 하는 pixel의 수이다.

모든 transition 및 animation은 JavaScript가 아닌 CSS로 적용되므로 main thread를 block 하지 (또는 block 되지) 않는다.

Actions

The use directive

action은 본질적으로 element-level lifecycle function이다.
다음과 같은 경우 유용하다.

  • interfacing with third-party libraries
  • lazy-loaded images
  • tooltips
  • adding custom event handlers

이 app에서는 사용자가 외부를 클릭할 때 주황색 modal이 닫히도록 하려고 한다.
outclick event에 대한 eventhandler가 있지만 native DOM event는 아니다.
우리가 직접 dispatch 해야 한다.
clickOutside function을 import 하고 element에 사용한다.

App.svelte :

<script>
    import { clickOutside } from "./click_outside.js";

    let showModal = true;
</script>

<button on:click={() => (showModal = true)}>Show Modal</button>
{#if showModal}
    <div class="box" use:clickOutside on:outclick={() => (showModal = false)}>
        Click outside me!
    </div>
{/if}

<style>
    .box {
        --width: 100px;
        --height: 100px;
        position: absolute;
        width: var(--width);
        height: var(--height);
        left: calc(50% - var(--width) / 2);
        top: calc(50% - var(--height) / 2);
        display: flex;
        align-items: center;
        padding: 8px;
        border-radius: 4px;
        background-color: #ff3e00;
        color: #fff;
        text-align: center;
        font-weight: bold;
    }
</style>

clock_outside.js :

export function clickOutside(node) {
    const handleClick = (event) => {
        if (!node.contains(event.target)) {
            node.dispatchEvent(new CustomEvent("outclick"));
        }
    };

    document.addEventListener("click", handleClick, true);

    return {
        destroy() {
            document.removeEventListener("click", handleClick, true);
        }
    };
}

transition function과 마찬가지로 action function은 node (action이 적용되는 element)와 일부 선택적 parameter를 전달받고 action object를 반환한다.
이 object는 element가 unmount 될 때 호출되는 destory function을 가질 수 있다.

Adding parameters

transition 및 animation과 마찬가지로 action은 argument를 취할 수 있으며 action function은 그것이 속한 element와 함께 호출된다.

다음 예제에서는 사용자가 버튼을 일정 시간 동안 누르고 있을 때마다 동일한 이름의 event를 발생시키는 longpress action을 사용하고 있다.

이때 얼마만큼 누르고 있으면 발생되는지에 대한 설정을 range input의 값을 통해 설정한다.
argument가 변경될 때마다 호출되는 update method를 longpress.js 에 추가한다.

App.svelte :

<script>
    import { longpress } from './longpress.js';

    let pressed = false;
    let duration = 2000;
</script>

<label>
    <input type=range bind:value={duration} max={2000} step={100}>
    {duration}ms
</label>

<button use:longpress={duration}
    on:longpress="{() => pressed = true}"
    on:mouseenter="{() => pressed = false}"
>press and hold</button>

{#if pressed}
    <p>congratulations, you pressed and held for {duration}ms</p>
{/if}

longpress.js :

export function longpress(node, duration) {
    let timer;

    const handleMousedown = () => {
        timer = setTimeout(() => {
            node.dispatchEvent(
                new CustomEvent('longpress')
            );
        }, duration);
    };

    const handleMouseup = () => {
        clearTimeout(timer)
    };

    node.addEventListener('mousedown', handleMousedown);
    node.addEventListener('mouseup', handleMouseup);

    return {
        update(newDuration) {
            duration = newDuration;
        },
        destroy() {
            node.removeEventListener('mousedown', handleMousedown);
            node.removeEventListener('mouseup', handleMouseup);
        }
    };
}

action에 여러 argument를 전달해야 하는 경우 다음과 같이 단일 객체로 결합한다.
use:longpress={{duration, spiciness}}

Advanced styling

The class directive

다른 attribute와 마찬가지로 다음과 같이 JavaScript attribute를 사용하여 class를 지정할 수 있다.

<button
    class="{current === 'foo' ? 'selected' : ''}"
    on:click="{() => current = 'foo'}"
>foo</button>

selected 를 명시하는 조건을 사용하였는데 svelte에선 위 방법을 좀 더 단순하게 사용할 수 있다.

<button
    class:selected="{current === 'foo'}"
    on:click="{() => current = 'foo'}"
>foo</button>

전체 예문은 다음과 같다.

<script>
    let current = 'foo';
</script>

<button
    class:selected="{current === 'foo'}"
    on:click="{() => current = 'foo'}"
>foo</button>

<button
    class:selected="{current === 'bar'}"
    on:click="{() => current = 'bar'}"
>bar</button>

<button
    class:selected="{current === 'baz'}"
    on:click="{() => current = 'baz'}"
>baz</button>

<style>
    button {
        display: block;
    }

    .selected {
        background-color: #ff3e00;
        color: white;
    }
</style>

Shorthand class directive

종종 class의 name은 class가 의존하는 값의 이름과 동일하다.

<div class:big={big}>
    <!-- ... -->
</div>

이런 경우 shorthand form을 사용할 수 있다.

<div class:big>
    <!-- ... -->
</div>

Inline styles

style tag 안에 style을 추가하는 것 외에도 style attribute를 사용하여 개별 element에 style을 추가할 수도 있다.
일반적으로 CSS를 통해 style을 지정하고 싶지만 이는 특히 CSS custom properties와 결합할 때 dynamic style에 유용할 수 있다.

paragraph element에 다음 style attribute를 추가한다.
style="color: {color}; --opacity: {bgOpacity};"

<script>
    let bgOpacity = 0.5;
    $: color = bgOpacity < 0.6 ? '#000' : '#fff';
</script>

<input type="range" min="0" max="1" step="0.1" bind:value={bgOpacity} />

<p style="color: {color}; --opacity: {bgOpacity};">This is a paragraph.</p>

<style>
    p {
        font-family: "Comic Sans MS", cursive;
        background: rgba(255, 62, 0, var(--opacity));
    }
</style>

The style directive

CSS properties를 동적으로 설정할 수 있는 것은 좋은 일이다.
그러나 긴 문자열을 작성해야 하는 경우 다루기 어려울 수 있다.
세미콜론을 누락하는 것과 같은 실수는 전체 문자열을 무효화할 수 있다.
따라서 svelte는 style 지시어로 inline style을 작성하는 더 좋은 방법을 제공한다.

paragraph의 style attribute를 다음과 같이 변경한다.

<p 
    style:color 
    style:--opacity="{bgOpacity}"
>

style 지시문은 class 지시문과 몇 가지 특성을 공유한다.
property name과 variable name이 같을 때 shorthand를 사용할 수 있다.
style:color="{color}"style:color 로 사용할 수 있다.

class 지시문과 유사하게 stype attribute를 통해 동일한 속성을 설정하려고 하면 style 지시문이 우선한다.

<script>
    let bgOpacity = 0.5;
    $: color = bgOpacity < 0.6 ? "#000" : "#fff";
</script>

<input type="range" min="0" max="1" step="0.1" bind:value={bgOpacity} />

<p style:color style:--opacity={bgOpacity}>This is a paragraph.</p>

<style>
    p {
        font-family: "Comic Sans MS", cursive;
        background: rgba(255, 62, 0, var(--opacity));
    }
</style>

Component composition

Slots

element가 자식을 가질 수 있는 것처럼...

<div>
    <p>I'm a child of the div</p>
</div>

component도 마찬가지이다.
그러나 component가 자식을 허용하려면 먼저 자식을 어디에 둘지 알아야 한다.
<slot> element를 사용하여 이 작업을 수행한다.
Box.svelte 안에 넣는다.

App.svelte :

<script>
    import Box from './Box.svelte';
</script>

<Box>
    <h2>Hello!</h2>
    <p>This is a box. It can contain anything.</p>
</Box>

Box.svelte :

<div class="box">
    <slot></slot>
</div>

<style>
    .box {
        width: 300px;
        border: 1px solid #aaa;
        border-radius: 2px;
        box-shadow: 2px 2px 8px rgba(0,0,0,0.1);
        padding: 1em;
        margin: 0 0 1em 0;
    }
</style>

Slot fallbacks

component는 <slot> element 안에 내용을 넣어 빈 slot에 대한 fallback을 지정할 수 있다.
아래 예제의 경우 첫 번째 box는 비어있지 않기 때문에 해당 내용이 표시되지만 두 번째 box의 경우 내용이 없기 때문에 slot의 fallback으로 대체되어 표시되었다.

App.svelte :

<script>
    import Box from './Box.svelte';
</script>

<Box>
    <h2>Hello!</h2>
    <p>This is a box. It can contain anything.</p>
</Box>

<Box/>

Box.svelte :

<div class="box">
    <slot>
        <em>no content was provided</em>
    </slot>
</div>

<style>
    .box {
        width: 300px;
        border: 1px solid #aaa;
        border-radius: 2px;
        box-shadow: 2px 2px 8px rgba(0,0,0,0.1);
        padding: 1em;
        margin: 0 0 1em 0;
    }
</style>

Named slots

이전 예제에는 component의 direct children 항목을 렌더링 하는 default slot이 포함되어 있다.
<ContactCard> 와 같이 배치에 더 많은 제어가 필요할 수 있다.
이 경우 Named slot을 사용할 수 있다.

ContactCard.svelte 의 각 slot에 name attribute를 추가한다.
<ContactCard> component에 해당 slot="…" attribute를 가진 element를 추가한다.

App.svelte :

<script>
    import ContactCard from './ContactCard.svelte';
</script>

<ContactCard>
    <span slot="name">
        P. Sherman
    </span>

    <span slot="address">
        42 Wallaby Way<br>
        Sydney
    </span>
</ContactCard>

ContactCard.svelte :

<article class="contact-card">
    <h2>
        <slot name="name">
            <span class="missing">Unknown name</span>
        </slot>
    </h2>

    <div class="address">
        <slot name="address">
            <span class="missing">Unknown address</span>
        </slot>
    </div>

    <div class="email">
        <slot name="email">
            <span class="missing">Unknown email</span>
        </slot>
    </div>
</article>

<style>
    .contact-card {
        width: 300px;
        border: 1px solid #aaa;
        border-radius: 2px;
        box-shadow: 2px 2px 8px rgba(0,0,0,0.1);
        padding: 1em;
    }

    h2 {
        padding: 0 0 0.2em 0;
        margin: 0 0 1em 0;
        border-bottom: 1px solid #ff3e00
    }

    .address, .email {
        padding: 0 0 0 1.5em;
        background:  0 0 no-repeat;
        background-size: 20px 20px;
        margin: 0 0 0.5em 0;
        line-height: 1.2;
    }

    .address {
        background-image: url(/tutorial/icons/map-marker.svg);
    }
    .email {
        background-image: url(/tutorial/icons/email.svg);
    }
    .missing {
        color: #999;
    }
</style>

이렇게 하면 명시된 slot이 없거나 비어있는 경우 fallback으로 대체되어 표시된다.

Checking for slot content

경우에 따라 상위 항목이 특정 slot에 대한 내용을 전달하는지 여부에 따라 component의 일부를 제어할 수 있다.
slot 주위에 wrapper가 있을 수 있으며 slot이 비어있으면 렌더링 하지 않을 수 있다.
또는 slot이 있는 경우에만 class를 적용할 수 있다.
특수한 $$slots variable을 확인하여 이 작업을 수행할 수 있다.

$$slots 는 key가 상위 component가 전달한 slot의 이름인 object이다.
부모가 slot을 비워두면 $$slots 에는 해당 slot에 대한 항목이 없다.

이 예에서 $$slot을 사용하지 않은 경우 <Project> 의 두 instance 모두 하나의 comment가 있더라도 comment를 위한 container와 (우측 상단 모서리에 위치한) 알림 표시를 렌더링 한다는 것에 유의해야 한다.
$$slots 를 사용하여 상위 <App>comment slot에 대한 content를 전달할 때만 이러한 element를 렌더링 하도록 한다.

예제가 너무 길어 <style> 태그 부분은 생략하였다.
전체 예문은 제목에 링크된 tutorial을 참고하면 된다.

App.svelte :

<script>
    import Project from './Project.svelte'
    import Comment from './Comment.svelte'
</script>

<h1>
    Projects
</h1>

<ul>
    <li>
        <Project
            title="Add Typescript support"
            tasksCompleted={25}
            totalTasks={57}
        >
            <div slot="comments">
                <Comment name="Ecma Script" postedAt={new Date('2020-08-17T14:12:23')}>
                    <p>Those interface tests are now passing.</p>
                </Comment>
            </div>
        </Project>
    </li>
    <li>
        <Project
            title="Update documentation"
            tasksCompleted={18}
            totalTasks={21}
        />
    </li>
</ul>

Project.svelte :

<script>
    export let title;
    export let tasksCompleted = 0;
    export let totalTasks = 0;
</script>

<article class:has-discussion={$$slots.comments}>
    <div>
        <h2>{title}</h2>
        <p>{tasksCompleted}/{totalTasks} tasks completed</p>
    </div>
    {#if $$slots.comments}
        <div class="discussion">
            <h3>Comments</h3>
            <slot name="comments"></slot>
        </div>
    {/if}
</article>

Comment.svelte :

<script>
    export let name;
    export let postedAt;

    $: avatar = `https://ui-avatars.com/api/?name=${name.replace(/ /g, '+')}&rounded=true&background=ff3e00&color=fff&bold=true`;
</script>

<article>
    <div class="header">
        <img src={avatar} alt="" height="32" width="32">
        <div class="details">
            <h4>{name}</h4>
            <time datetime={postedAt.toISOString()}>{postedAt.toLocaleDateString()}</time>
        </div>
    </div>
    <div class="body">
        <slot></slot>
    </div>
</article>

Slot props

이 app에는 현재 마우스가 위에 있는지 여부를 추적하는 <Hoverable> component가 있다.
slot content를 업데이트할 수 있도록 해당 데이터를 상위 component에 다시 전달해야 한다.

이를 위해 slot props를 사용한다.
Hoverable.svelte 에서 slot에 hovering 값을 전달한다.
그런 다음 hovering을 <Hoverable> component의 내용에 노출하기 위해 let 지시어를 사용한다.

App.svelte :

<script>
    import Hoverable from './Hoverable.svelte';
</script>

<Hoverable let:hovering={active}>
    <div class:active>
        {#if active}
            <p>I am being hovered upon.</p>
        {:else}
            <p>Hover over me!</p>
        {/if}
    </div>
</Hoverable>

<Hoverable let:hovering={active}>
    <div class:active>
        {#if active}
            <p>I am being hovered upon.</p>
        {:else}
            <p>Hover over me!</p>
        {/if}
    </div>
</Hoverable>

<Hoverable let:hovering={active}>
    <div class:active>
        {#if active}
            <p>I am being hovered upon.</p>
        {:else}
            <p>Hover over me!</p>
        {/if}
    </div>
</Hoverable>

<style>
    div {
        padding: 1em;
        margin: 0 0 1em 0;
        background-color: #eee;
    }

    .active {
        background-color: #ff3e00;
        color: white;
    }
</style>

Hoverable.svelte :

<script>
    let hovering;

    function enter() {
        hovering = true;
    }

    function leave() {
        hovering = false;
    }
</script>

<div on:mouseenter={enter} on:mouseleave={leave}>
    <slot hovering={hovering}></slot>
</div>

이런 component는 원하는 만큼 가질 수 있으며 slot이 있는 props는 선언된 component에 local로 유지된다.

Context API

setContext and getContext

Context API는 data와 function을 props로 전달하거나 많은 event를 전달하지 않고 component가 서로 `대화`할 수 있는 메커니즘을 제공한다.
고급 기능이지만 유용한 기능이다.

Mapbox GL map을 사용하여 이 예제 앱을 사용한다.
<MapMarker> component를 사용하여 marker를 표시하고 싶지만 기본 Mapbox instance에 대한 참조를 각 component의 props로 전달할 필요가 없다.

context API는 두 가지가 있다. - setContextgetContext
component가 setContext(key, context) 를 호출하는 경우 하위 component는 const context = getContext(key) 로 찾을 수 있다.

Map.svelte 에서 setContext 를 import 하고 mapbox.js 에서 key 를 가져와 setContext 를 호출한다.

import { onDestroy, setContext } from 'svelte';
import { mapbox, key } from './mapbox.js';

setContext(key, {
    getMap: () => map
});

context object는 어느 것이든 사용할 수 있다.
lifecycle function과 마찬가지로 component를 초기화하는 동안 setContextgetContext 를 호출해야 한다.
나중에 호출하면 (예: onMount 에서 호출) 오류가 발생한다.
이 예에서 map 은 component가 mount 될 때까지 생성되지 않으므로 context object에는 map 자체가 아닌 getMap function이 포함된다.

MapMarker.svelte 에서 Mapbox instance에 대한 참조를 얻을 수 있다.

import { getContext } from 'svelte';
import { mapbox, key } from './mapbox.js';

const { getMap } = getContext(key);
const map = getMap();

이제 marker가 지도에 자신을 추가할 수 있다.

좀 더 완성된 버전의 <MapMarker> 도 제거 및 prop 변경을 처리할 수 있지만 여기서는 context만 사용하였다.

Context keys

mapbox.js 에서 다음과 같은 라인을 확인할 수 있다.

const key = Symbol();

기술적으로 어떤 값도 key로 사용할 수 있다.
예를 들어 setContext('mapbox', …) 를 사용할 수 있다.
문자열을 사용하는 단점은 다른 component library가 실수로 동일한 component library를 사용할 수 있다는 것이다.
반면에 symbol을 사용하면 symbol이 본질적으로 고유한 식별자이기 때문에 여러 component 계층에서 작동하는 여러 가지 context가 있는 경우에도 key가 어떤 상황에서도 충돌하지 않도록 보장된다.

Context vs. stores

context와 store는 비슷해 보인다.
store는 app의 모든 부분에서 사용할 수 있지만 context는 component와 해당 하위 항목에서만 사용할 수 있다는 점이 다르다.
한 component의 state가 다른 component의 state를 간섭하지 않고 여러 component의 instance를 사용하려는 경우 유용할 수 있다.

그 두 가지를 같이 사용할지도 모른다.
context는 반응적(reactive)이지 않기 떄문에 시간에 따라 변하는 값은 store로 사용해야 한다.

const { these, are, stores } = getContext(...);

Special elements

svelte:self

Svelte는 다양한 build-in element를 제공한다.
첫 번째, <svelte:self> 는 component가 자신을 재귀적으로 포함하도록 허용한다.

folder가 다른 folder를 포함할 수 있는 folder tree view 같은 경우에 유용하다.
Folder.svelte 에서 아래같이 사용하길 원하지만

{#if file.files}
    <Folder {...file}/>
{:else}
    <File {...file}/>
{/if}

module이 자체적으로 자신을 import 할 수 없기 때문에 대신 <svelte:self> 를 다음과 같이 사용한다.

{#if file.files}
    <svelte:self {...file}/>
{:else}
    <File {...file}/>
{/if}

svelte:component

component는 if block 대신 해당 category를 <svelte:componet> 와 함께 변경할 수 있다.

{#if selected.color === 'red'}
    <RedThing/>
{:else if selected.color === 'green'}
    <GreenThing/>
{:else if selected.color === 'blue'}
    <BlueThing/>
{/if}

아래처럼 하나의 dynamic component를 사용할 수 있다.

<svelte:component this={selected.component}/>

this 값은 component constructor 또는 거짓(falsy) 값일 수 있다.
거짓(falsy) 값이면 component가 렌더링 되지 않는다.

svelte:element

어떤 종류의 DOM element를 렌더링해야 할지 미리 할 수 없는 경우도 있다.
if block 대신 <svelte:element> 가 이런 경우 유용하다.

{#if selected === 'h1'}
    <h1>I'm a h1 tag</h1>
{:else if selected === 'h3'}
    <h3>I'm a h3 tag</h3>
{:else if selected === 'p'}
    <p>I'm a p tag</p>
{/if}

아래처럼 하나의 dynamic component를 사용할 수 있다.

<svelte:element this={selected}>I'm a {selected} tag</svelte:element>

this 값은 임의의 문자열 또는 거짓(falsy) 값일 수 있다.
거짓(falsy) 값이면 element가 렌더링 되지 않는다.

svelte:window

DOM element에 event listener를 추가할 수 있는 것과 마찬가지로 <svelte:window> 를 사용하여 window object에 event listener를 추가할 수 있다.

<script>
    let key;
    let keyCode;

    function handleKeydown(event) {
        key = event.key;
        keyCode = event.keyCode;
    }
</script>

<svelte:window on:keydown={handleKeydown}/>

<div style="text-align: center">
    {#if key}
        <kbd>{key === ' ' ? 'Space' : key}</kbd>
        <p>{keyCode}</p>
    {:else}
        <p>Focus this window and press any key</p>
    {/if}
</div>

<style>
    div {
        display: flex;
        height: 100%;
        align-items: center;
        justify-content: center;
        flex-direction: column;
    }

    kbd {
        background-color: #eee;
        border-radius: 4px;
        font-size: 6em;
        padding: 0.2em 0.5em;
        border-top: 5px solid rgba(255, 255, 255, 0.5);
        border-left: 5px solid rgba(255, 255, 255, 0.5);
        border-right: 5px solid rgba(0, 0, 0, 0.2);
        border-bottom: 5px solid rgba(0, 0, 0, 0.2);
        color: #555;
    }
</style>

DOM element와 마찬가지로 preventDefault 와 같은 event modifier를 추가할 수 있다.

svelte:window bindings

scrollY 같은 window 특정 property를 binding 할 수 있다.

예제가 너무 길어 <style> 태그 부분은 생략하였다.
전체 예문은 제목에 링크된 tutorial을 참고하면 된다.

<script>
    const layers = [0, 1, 2, 3, 4, 5, 6, 7, 8];

    let y;
</script>

<svelte:window bind:scrollY={y}/>

<a class="parallax-container" href="https://www.firewatchgame.com">
    {#each layers as layer}
        <img
            style="transform: translate(0,{-y * layer / (layers.length - 1)}px)"
            src="https://www.firewatchgame.com/images/parallax/parallax{layer}.png"
            alt="parallax layer {layer}"
        >
    {/each}
</a>

<div class="text">
    <span style="opacity: {1 - Math.max(0, y / 40)}">
        scroll down
    </span>

    <div class="foreground">
        You have scrolled {y} pixels
    </div>
</div>

binding 할 수 있는 property 목록은 다음과 같다.

  • innerWidth
  • innerHeight
  • outerWidth
  • outerHeight
  • scrollX
  • scrollY
  • online - window.navigator.onLine 의 축약

scrollXscrollY 를 제외한 나머지는 모두 readonly이다.

svelte:body

<svelte:window> 와 유사하게 <svelte:body> element를 사용하면 document.body 에서 발생하는 이벤트를 listen 할 수 있다.
이 기능은 window 에서 실행되지 않는 mouseentermouseleae event에 유용하다.

mouseentermouseleave handler를 <svelte:body> tag에 추가한다.

<script>
    let hereKitty = false;

    const handleMouseenter = () => hereKitty = true;
    const handleMouseleave = () => hereKitty = false;
</script>

<svelte:body
    on:mouseenter={handleMouseenter}
    on:mouseleave={handleMouseleave}
/>

<!-- creative commons BY-NC http://www.pngall.com/kitten-png/download/7247 -->
<img
    class:curious={hereKitty}
    alt="Kitten wants to know what's going on"
    src="/tutorial/kitten.png"
>

<style>
    img {
        position: absolute;
        left: 0;
        bottom: -60px;
        transform: translate(-80%, 0) rotate(-30deg);
        transform-origin: 100% 100%;
        transition: transform 0.4s;
    }

    .curious {
        transform: translate(-15%, 0) rotate(0deg);
    }

    :global(body) {
        overflow: hidden;
    }
</style>

svelte:head

<svelte:head> element는 문서의 <head> 내부에 element를 삽입할 수 있다.

<svelte:head>
    <link rel="stylesheet" href="/tutorial/dark-theme.css">
</svelte:head>

<h1>Hello world!</h1>

SSR (Server-Side Rendering) 모드에서는 <svelte:head> 의 내용이 HTML의 나머지 부분과 별도로 반환된다.

svelte:options

<svelte:options> element를 사용하면 compiler option을 지정할 수 있다.

immutable option을 예로 들면 이 app에서 새 data를 전달받을 때마다 <Todo> component가 깜빡인다.
항목 중 하나를 클릭하면 업데이트된 todo array를 만들어 done state가 토글 된다.
이로 인해 다른 <Todo> item은 DOM을 변경하지 않아도 깜빡인다.

<Todo> component에 immutable data를 기대하도록 지시함으로써 이를 최적화할 수 있다.
이는 todo prop를 절대 변형시키지 않고 대신 변경될 때마다 새로운 todo object를 만들 것을 약속한다는 것을 의미한다.

Todo.svelte 최상단에 아래를 추가한다.

<svelte:options immutable={true}/>

원하는 경우 <svelte:options immutable /> 로 단축할 수 있다.

이제 todo를 클릭하여 토글 하면 업데이트된 component만 깜빡인다.

여기서 설정할 수 있는 옵션은 다음과 같다.

  • immutable={true} - 절대 변경 가능한 data를 사용하지 않는다. 그래서 compiler는 값이 변경되었는지 결정하기 위해 간단한 참조 동등성 check를 할 수 있다.
  • immutable={false} - 기본 값, Svelte는 변경 가능한 객체가 변경되었는지 여부에 대해 더 보수적이다.
  • accessors={true} - component의 prop에 getter 및 setter 추가
  • accessors={false} - 기본 값
  • namespace="…" - component에서 사용될 namespace, 대부분 "svg"
  • tag="…" - component를 custom element로 compile 할 때 사용할 이름

option에 대한 자세한 내용은 API reference를 참조하면 된다.

svelte:fragment

<svelte:fragment> element를 사용하면 container DOM element에 wrapping 하지 않고 지정된 slot에 content를 배치할 수 있다.
이렇게 하면 document의 flow layout이 그대로 유지된다.

예제에서는 간격이 1em 인 flex layout을 box에 적용한 방법을 알아본다.

<!-- Box.svelte -->
<div class="box">
    <slot name="header">No header was provided</slot>
    <p>Some content between header and footer</p>
    <slot name="footer"></slot>
</div>

<style>
    .box {        
        display: flex;
        flex-direction: column;
        gap: 1em;
    }
</style>

footer의 content를 div로 감싸면 새로운 flow layout이 만들어지기 때문에 공백이 생기지 않는다.

App component에서 <div slot="footer"> 를 변경하여 이 문제를 해결할 수 있다.
<div><svelte:fragment> 로 바꾼다.

<svelte:fragment slot="footer">
    <p>All rights reserved.</p>
    <p>Copyright (c) 2019 Svelte Industries</p>
</svelte:fragment>

Module context

Sharing code

지금까지 살펴본 모든 예제에서 <script> block에는 각 component instance가 초기화될 때 실행되는 code가 포함되어 있었다.
대부분의 component에 대해 이것이 필요한 전부이다.

아주 가끔 개별 component instance 외부에서 일부 코드를 실행해야 한다.
예를 들어 이 다섯 가지 오디오 플레이어를 동시에 재생할 수 있다.
하나를 재생할 때 다른 것은 중지되도록 하는 게 더 좋을 것이다.

<script context="module"> block을 선언하여 이를 수행할 수 있다.
내부에 포함된 코드는 component가 instance 화 될 때가 아니라 module이 처음 평가될 때 한번 실행된다.
이것을 AudioPlayer.svelte 상단에 배치한다.

<script context="module">
    let current;
</script>

이제 component가 state management 없이 서로 '대화' 할 수 있다.

function stopOthers() {
    if (current && current !== audio) current.pause();
    current = audio;
}

Exports

context="module" script block에서 내보낸 모든 것은 module 자체에서 export가 된다.
AudioPlayer.svelte 에서 stopAll funcion을 export 하면

<script context="module">
    const elements = new Set();

    export function stopAll() {
        elements.forEach(element => {
            element.pause();
        });
    }
</script>

App.svelte 에서 import 할 수 있다.

<script>
    import AudioPlayer, { stopAll } from './AudioPlayer.svelte';
</script>

그리고 event handler에서 사용한다.

<button on:click={stopAll}>
    stop all audio
</button>

component가 default export 이기 때문에 따로 default export를 가질 수 없다.

The @debug tag

경우에 따라 데이터가 app을 통과할 때 데이터를 검사하는 것이 유용하다.

한 가지 방법은 markup 내에서 console.log(…) 를 사용하는 것이다.
그러나 실행을 일시 중지하려면 검사하려는 값의 쉼표로 구성된 목록과 함께 {@debug …} tag를 사용할 수 있다.

{@debug user}

<h1>Hello {user.firstname}!</h1>

이제 devtools를 열고 <input> element와 상호작용하기 시작하면 user 값이 변경될 때 debugger를 trigger 한다.

Congratulations!

이제 Svelte tutorial을 마치고 app build를 시작할 준비가 되었다.
언제든지 개별 장을 다시 참조하거나 API reference, 예제블로그를 통해 학습을 계속할 수 있다.
트위터 사용자라면 @sveltejs를 통해 업데이트를 받을 수 있다.

local 개발 환경에서 설정하려면 빠른 시작 가이드를 확인하면 된다.

routing, server-side rendering 및 기타 모든 것을 포함하는 보다 광범위한 framework를 찾고 있으면 SvelteKit을 살펴보면 된다.

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요