Svelte의 수명 주기 함수

Svelte 수명 주기 가이드

onMount

Svelte에서 가장 자주 사용하게 될 onMount는 컴포넌트가 처음 DOM에 렌더링된 후에 실행이 되는데, 다음과 같이 네트워크를 통해 일부 데이터를 로드하는 onMount 핸들러를 추가할 수 있어.

onMount 사용 예제
<script>
    import { onMount } from 'svelte';

    let photos = [];

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

위 코드의 경우 서버측 렌더링 때문에 <script>의 최상위 레벨이 아닌 onMountfetch를 넣어주는 것이 좋은데, onDestroy를 제외하고 수명 주기 기능은 서버측 렌더링 동안은 실행되지 않아. 즉, 컴포넌트가 DOM에 마운트된 후에 지연 로드되어야 하는 데이터를 가져오는 것을 피할 수 있어.

수명 주기 함수는 컴포넌트가 초기화되는 동안 호출되고, 콜백은 setTimeout이 아닌 컴포넌트 인스턴스에 바인딩되는데, onMount 콜백이 함수를 반환하면 컴포넌트가 파괴될 때 해당 함수가 호출돼.

onMount 예제 전체 코드
App.svelte
<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}
        <!-- 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>

onDestroy

컴포넌트가 파괴될 때 코드를 실행하려면 onDestroy를 사용하면 돼. 예를 들어 컴포넌트가 초기화될 때 setInterval 함수를 추가하고 더 이상 관련이 없으면 정리할 수 있는데, 이렇게 하면 메모리 누수를 방지할 수 있어.

onDestroy 예제
<script>
    import { onDestroy } from 'svelte';

    let counter = 0;
    const interval = setInterval(() => counter += 1, 1000);

    onDestroy(() => clearInterval(interval));
</script>

컴포넌트를 초기화하는 동안에는 수명 주기 함수를 호출하는 것이 중요한데, 호출한 위치는 중요하지 않기 때문에 원한다면 interval logic을 다음과 같이 utils.js와 같은 도우미 함수로 추상화할 수도 있어.

utils.js
import { onDestroy } from 'svelte';

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

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

추상화한 utils.js는 다음과 같이 컴포넌트로 가져올 수 있어.

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

    let counter = 0;
    onInterval(() => counter += 1, 1000);
</script>

타이머를 몇 번 열고 닫으면서 카운터가 계속 똑딱거리고 CPU 부하가 증가하는 것이 확인된다면, 이는 이전 타이머가 삭제되지 않아 메모리 누수가 발생하기 때문이야. 다음은 onDestroy 예제의 전체 코드야.

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);
    });
}

beforeUpdate와 afterUpdate

beforeUpdate 함수는 DOM이 업데이트되기 직전에 작업을 실행하고, afterUpdate는 DOM이 데이터와 동기화되면 코드를 실행하는 수명 주기 함수야.

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 함수는 컴포넌트가 마운트되기 전에 먼저 실행되기 때문에, 속성을 읽기 전에 div 엘리먼트가 있는지 확인할 필요가 있어.

Eliza 챗봇 예제
App.svelte
<script>
    import Eliza from 'elizabot';
    import { beforeUpdate, afterUpdate } from 'svelte';

    let div;
    let autoscroll;

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

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

    const eliza = new Eliza();

    let comments = [
        { author: 'eliza', text: eliza.getInitial() }
    ];

    function handleKeydown(event) {
        if (event.key === 'Enter') {
            const text = event.target.value;
            if (!text) return;

            comments = comments.concat({
                author: 'user',
                text
            });

            event.target.value = '';

            const reply = eliza.transform(text);

            setTimeout(() => {
                comments = comments.concat({
                    author: 'eliza',
                    text: '...',
                    placeholder: true
                });

                setTimeout(() => {
                    comments = comments.filter(comment => !comment.placeholder).concat({
                        author: 'eliza',
                        text: reply
                    });
                }, 500 + Math.random() * 500);
            }, 200 + Math.random() * 200);
        }
    }
</script>

<style>
    .chat {
        display: flex;
        flex-direction: column;
        height: 100%;
        max-width: 320px;
    }

    .scrollable {
        flex: 1 1 auto;
        border-top: 1px solid #eee;
        margin: 0 0 0.5em 0;
        overflow-y: auto;
    }

    article {
        margin: 0.5em 0;
    }

    .user {
        text-align: right;
    }

    span {
        padding: 0.5em 1em;
        display: inline-block;
    }

    .eliza span {
        background-color: #eee;
        border-radius: 1em 1em 1em 0;
    }

    .user span {
        background-color: #0074D9;
        color: white;
        border-radius: 1em 1em 0 1em;
        word-break: break-all;
    }
</style>

<div class="chat">
    <h1>Eliza</h1>

    <div class="scrollable" bind:this={div}>
        {#each comments as comment}
            <article class={comment.author}>
                <span>{comment.text}</span>
            </article>
        {/each}
    </div>

    <input on:keydown={handleKeydown}>
</div>

tick

tick 함수는 컴포넌트가 처음 초기화될 때뿐만 아니라 언제든지 호출할 수 있다는 점에서 다른 수명 주기 함수와는 달라.

tick 함수는 보류 중인 상태 변경 사항이 DOM에 적용되는 즉시, 또는 보류 중인 상태 변경 사항이 없는 경우에는 즉시 약속된 동작을 반환할 수 있어.

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

다음의 예제에서 그 동작을 볼 수 있는데, 텍스트 범위를 선택하고 탭 키를 누르면 <textarea> 값이 변경되기 때문에 현재의 선택이 지워지고 커서가 끝으로 이동하게 돼.

tick를 적용하지 않은 경우
App.svelte
<script>
    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)
        );

        // this has no effect, because the DOM hasn't updated yet
        this.selectionStart = selectionStart;
        this.selectionEnd = selectionEnd;
    }
</script>

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

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

위의 코드에 다음과 같이 tick를 추가해주고 handleKeydown 끝에 this.selectionStartthis.selectionEnd의 직전에 실행하도록 추가해주면 커서가 이동하지 않고 유지되는 것을 확인할 수 있어.

import { tick } from 'svelte';
await tick();
this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;

다음은 tick을 적용한 예제의 전체 코드야.

tick을 적용한 경우
App.svelte
<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>

답글 남기기