Svelte 데이터 바인딩

Svelte 바인딩 가이드

Text Input

일반적으로 Svelte의 데이터 흐름은 하향식입니다. 즉 상위 구성 요소는 하위 구성 요소에 props를 설정할 수 있고, 구성 요소는 요소에 속성을 설정할 수 있지만 그 반대로는 할 수 없습니다.

<input> 구성 요소와 화면의 데이터가 연동되도록 구현하기 위해서는 name의 값을 event.target.value로 설정하는 on:input 이벤트 핸들러를 추가해야 하는데, bind:value 디렉티브를 사용하면 name의 값이 변경될 때 입력 값이 업데이트되고, 입력 값이 변경될 때 name도 함께 업데이트되는 동작을 간단하게 구현할 수 있습니다.

input과 데이터 바인딩하기
App.svelte
<script>
    let name = 'world';
</script>

<input bind:value={name}>

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

Numeric Inputs

DOM에서 모든 것은 문자열로 처리되기 때문에 type="number", type="range"와 같이 숫자 입력을 처리하는 경우에는 다소 복잡해 질 수 있는데, input.value의 type을 강제로 변환해야 하기 때문입니다.

하지만 다음 코드와 같이 Svelte에서는 bind:value 디렉티브로 숫자형 Input을 간단하게 처리할 수 있습니다.

숫자형 Input 바인딩하기
App.svelte
<script>
    let a = 1;
    let b = 2;
</script>

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

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

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

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

Checkbox Inputs

체크박스는 일반적으로 상태를 전환하기 위해 사용되는데, input.value가 아닌 input.checked에 바인딩하는 UI입니다.

체크박스 바인딩하기
App.svelte
<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>

Group Inputs

동일한 값과 관련된 입력이 여러 개인 경우에는 value 속성과 함께 bind:group 디렉티브를 사용할 수 있습니다. 동일한 그룹에서 Radio Input은 하나의 Radio 버튼만 선택 가능하지만, 동일한 그룹에서 Checkbox Input은 선택한 값의 배열을 생성하는데, 다음과 같이 각 입력에 bind:group을 추가해주면 됩니다.

그룹 속성 바인딩하기
<input type=radio bind:group={scoops} name="scoops" value={1}>

위 코드의 경우 Checkbox Input은 each 블록과 함께 코드를 더 간단하게 만들 수 있습니다.

menu 변수 추가하기
let menu = [
    'Cookies and cream',
    'Mint choc chip',
    'Raspberry ripple'
];

우선 <script> 블록에 menu 변수를 추가한 후, 다음과 같이 두 번째 섹션을 바꿔주면 됩니다.

Checkbox 메뉴 만들기
{#each menu as flavour}
    <label>
        <input type=checkbox bind:group={flavours} name="flavours" value={flavour}>
        {flavour}
    </label>
{/each}

이렇게 bind:group 디렉티브를 사용하면 간단하게 목록을 확장할 수 있습니다.

Radio 및 Checkbox Input 바인딩 예제
App.svelte
<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}

Textarea

Textarea 요소는 Svelte의 Text Input과 같이 bind:value를 사용하는데, value와 동일한 변수를 사용하는 경우에는 bind:value={value}가 아닌 bind:value 처럼 축약된 형태로 사용할 수도 있습니다.

Textarea 사용법
<textarea bind:value={value}></textarea>
변수가 같은 경우 축약하기
<textarea bind:value></textarea>

참고로 축약 방식은 textarea뿐만 아니라 모든 바인딩에도 적용되는 방식입니다.

Textarea 바인딩하기
App.svelte
<script>
    import { marked } from 'marked';
    let value = `Some words are *italic*, some are **bold**`;
</script>

{@html marked(value)}

<textarea bind:value></textarea>

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

Select

Select 역시 Text Input과 마찬가지로 bind:value 디렉티브를 사용하면 되는데, option 값은 문자열이 아닌 객체를 사용해야 됩니다.

Select 바인딩하기
<select bind:value={selected} on:change="{() => answer = ''}">

selected의 초기 값을 설정하지 않으면 바인딩은 자동으로 목록의 첫 번째 값을 기본값으로 설정하는데, 바인딩이 초기화될 때까지 selected는 정의되지 않은 상태로 남아 있기 때문에, 이 문제를 우회하기위해 초기 값을 미리 설정해 둘 수도 있습니다.

Select 바인딩 예제
App.svelte
<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>

다중 선택 Select

Select는 multiple 속성을 가질 수 있는데, 이런 경우에는 단일 값을 선택하는 대신 배열을 생성하게 됩니다.

Select에 multiple 속성 사용하기
<select multiple bind:value={flavours}>
    {#each menu as flavour}
        <option value={flavour}>
            {flavour}
        </option>
    {/each}
</select>

multiple 속성을 사용하는 경우 여러 옵션을 선택하려면 control 키를 누른 상태에서 다른 옵션을 선택하면 됩니다.

Select multiple 속성 사용 예제
App.svelte
<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}

Contenteditable

Contenteditable 바인딩하기
<div
    contenteditable="true"
    bind:innerHTML={html}
></div>

위의 코드와 같이 contenteditable="true" 속성이 있는 요소는 textContentinnerHTML 바인딩을 사용할 수 있습니다.

App.svelte
<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 블록 바인딩

Svelte는 each 블록 내부의 속성에 바인딩할 수도 있습니다.

Each 블록 바인딩하기
{#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}

위의 코드와 같이 input 요소에 바인딩을 하면 배열이 변경되는데, 만약 변경할 수 없는 데이터로 작업을 해야 하는 경우라면 바인딩 대신 이벤트 핸들러를 사용하는 것이 좋습니다.

Each 블록 바인딩 예제
App.svelte
<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>

미디어 요소

audiovideo 요소에는 다음과 같이 바인딩할 수 있는 여러 속성이 있는데, bind:durationbind:duration={duration}의 축약 형태입니다.

Video 요소에 바인딩하기
<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>

위의 코드와 같이 여러 속성을 바인딩한 후 동영상을 클릭하면 time, duration, paused를 적절하게 업데이트되는데, 이렇게 다양한 속성들을 사용하면 사용자 지정 컨트롤을 쉽게 구현할 수 있습니다.

또 일반적으로 웹에서는 timeupdate 이벤트를 수신하여 currentTime을 추적하기 때문에 이벤트가 너무 드물게 실행되어 UI가 일정하지 않게 되지만, Svelte는 requestAnimationFrame을 사용하여 currentTime을 확인하기 때문에 더 나은 방식이라고 할 수 있습니다.

audiovideo에 대한 전체 바인딩 속성는 다음과 같은데, video에는 추가로 읽기 전용 videoWidthvideoHeight 바인딩이 있습니다.

읽기 전용 바인딩

  • duration(readonly): 비디오의 총 지속 시간(초)
  • buffered(readonly): {start, end} 객체의 배열
  • seekable(readonly): 위와 같음
  • played(readonly): 위와 같음
  • seeking(readonly): boolean
  • ended(readonly): boolean

양방향 바인딩

  • currentTime: 비디오의 현재 지점(초)
  • playbackRate: 비디오를 재생하는 속도(1은 ‘보통’)
  • paused
  • volume: 0과 1 사이의 값
  • muted: boolean(true일 경우 ‘음소거’)
Video 바인딩 예제
App.svelte
<script>
    // 비디오 속성에 바인딩되는 값들
    let time = 0;
    let duration;
    let paused = true;

    let showControls = true;
    let showControlsTimeout;

    // 마지막 마우스 다운 이벤트의 시간을 추적
    let lastMouseDown;

    function handleMove(e) {
        // 컨트롤을 보이게 한 후 페이드 아웃 처리, 2.5초 동안 비활성됨
        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);
    }

    // 내장된 클릭 이벤트가 발생하기 때문에 드래그 후 직접 클릭을 수신함
    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>

수치 변경

모든 블록 수준의 요소에서는 clientWidth, clientHeight, offsetWidth, offsetHeight 바인딩을 사용할 수 있습니다.

수치 속성 바인딩
<div bind:clientWidth={w} bind:clientHeight={h}>
    <span style="font-size: {size}px">{text}</span>
</div>

clientWidth, clientHeight, offsetWidth, offsetHeight는 모두 읽기 전용의 속성으로, 위의 코드에서 wh 값을 변경해도 해당 엘리먼트에는 아무런 변화가 나타나지는 않습니다.

대부분의 블록 요소는 이와 유사한 기술을 사용하여 측정될 수 있지만 약간의 오버헤드가 발생되기 때문에 많은 요소에서 수치를 측정하는 바인드를 사용하는 것은 권장되지 않습니다.

display: inline 요소와 canvas 처럼 다른 요소를 포함할 수 없는 요소는 이런 방식으로는 측정할 수가 없는데, 이런 요소의 사이즈를 측정해야 하는 경우에는 래퍼 요소를 대신 측정할 필요가 있습니다.

수치 변경 바인딩 예제
App.svelte
<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>

This

읽기 전용의 this 바인딩은 모든 요소와 컴포넌트에 적용되고, 렌더링된 요소에 대한 참조를 얻을 수 있습니다.

This 바인딩하기
<canvas
    bind:this={canvas}
    width={32}
    height={32}
></canvas>

위의 코드에서 canvas 값은 구성 요소가 마운트될 때까지 undefined가 되는데, 이를 피하려면 수명 주기에서 onMount 함수 안에 로직을 넣어주면 됩니다.

렌더링된 요소에 대한 참조 얻기 예제
App.svelte
<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>

컴포넌트 바인딩

DOM 요소의 속성에 바인딩할 수 있는 것처럼 컴포넌트의 props에도 바인딩을 할 수 있습니다.

컴포넌트에 바인딩하기
<Keypad bind:value={pin} on:submit={handleSubmit}/>

위의 코드와 같이 컴포넌트의 bind:valuepin이라는 변수를 바인딩하면, 사용자가 키패드와 상호 작용을 할 때 상위 구성 요소의 pin 값이 즉시 업데이트될 수 있습니다.

컴포넌트 바인딩은 자주 사용되지는 않는데, 데이터가 너무 많은 경우에는 애플리케이션 주변의 데이터 흐름을 추적하기 어려울 수도 있기 때문입니다.

컴포넌트 바인딩 예제
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 컴포넌트
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>

컴포넌트 인스턴스 바인딩

DOM 요소에 바인딩할 수 있는 것처럼 컴포넌트의 인스턴스 자체에도 바인딩을 할 수 있는데, 다음과 같이 DOM 요소를 바인딩할 때와 같이 <InputField>의 인스턴스를 field라는 변수에 바인딩할 수 있습니다.

컴포넌트 인스턴스에 바인딩하기
<script>
    let field;
</script>

<InputField bind:this={field} />

위와 같이 field라는 변수에 바인딩을 하면, 다음과 같이 field를 사용하여 이 컴포넌트와 상호 작용을 할 수 있습니다.

컴포넌트에 이벤트 연결하기
<button on:click="{() => field.focus()}">
    Focus field
</button>

만약 버튼이 처음 렌더링되고 오류가 발생한다면 field가 정의되지 않아 {field.focus}를 수행할 수 없기 때문입니다.

컴포넌트 인스턴스 바인딩 예제
App.svelte
<script>
    import InputField from './InputField.svelte';

    let field;
</script>

<InputField bind:this={field}/>

<button on:click={() => field.focus()}>Focus field</button>
InputField 컴포넌트
InputField.svelte
<script>
    let input;

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

<input bind:this={input} />

답글 남기기