99

최근 30분간 동시 방문자 수를 표시합니다. (~)

최고 동시 방문자 수 -
어제: 0명 / 오늘: 0명

Vue Composition API 핵심 패턴 with TS

Vue Composition API 핵심 패턴 with TS

이 글은 Vue 3버전의 기초 문법 학습을 전제하여, 핵심 패턴을 빠르게 탐색할 수 있도록 구성했습니다.
파편화된 핵심 정보를 모으고 공식 문서와 다른 새로운 예제를 제공합니다.
또한 이 글은 Vite 'Vue > TypeScript' 템플릿을 기준으로 설명하며, Composition API와 타입스크립트를 사용합니다.

이 글은 'Vite로 시작하는 Vue 프로젝트'를 기반으로 설명합니다.

이 글은 Vue 3.5 버전을 기준으로 작성되었습니다.
특정 버전 이상이 필요한 기능은 3.5+와 같이 표기합니다.

# Composition VS Options

Options API는 data, methods와 같이 강제되는 옵션을 사용해서 자바스크립트 이해도가 높지 않아도 쉽게 배우고 사용할 수 있습니다.
또한 Vue 2버전 이하의 기존 코드와 호환성을 유지하기 좋습니다.

Composition API는 새로운 스크립트 문법으로 더 유연한 구조로 코드 재사용성이 향상됩니다.
또한 타입스크립트와의 호환이 좋습니다.(Options API도 타입스크립트와 함께 사용할 수 있지만 일부 불편한 부분이 있습니다)

두 방식은 하나의 프로젝트에서 함께 사용할 수 있습니다.
만약, 타입스크립트를 사용한다면 Composition API를 사용하는 것을 추천합니다.

# 앱 생성하기

/index.html
HTML
1
2
<div id="app"></div> <script type="module" src="/src/main.ts"></script>
/src/main.ts
JS
1
2
3
4
5
6
7
8
9
import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) // 앱 인스턴스 반환! app.use(플러그인) // 플러그인 등록! app.component(컴포넌트) // 전역 컴포넌트 등록! app.directive(디렉티브) // 디렉티브 등록! // ... app.mount('#app') // 마지막에 호출!

메소드 체이닝을 사용하는 경우,

JS
1
2
3
4
createApp(App) .use(플러그인) .component(컴포넌트) .mount('#app')

# 앱 인스턴스 멤버

멤버 설명 비고
app.mount() 요소에 앱 인스턴스를 연결(Mount)
app.unmount() 요소에 연결된 앱 인스턴스를 해제(Unmount)
app.onUnmount() 앱 인스턴스가 해제될 때 호출할 정리 콜백을 등록 3.5+
app.use() Vue 외부 플러그인을 설치
app.component() 전역 컴포넌트를 등록
app.directive() 전역 커스텀 디렉티브를 등록
app.provide() 전역으로 주입(Inject)할 데이터를 제공(Provide)
app.mixin() 전역 믹스인을 적용 하위 호환 지원을 위하며, 직접 사용은 권장하지 않음
app.runWithContext() app.provide()로 전역 제공하는 데이터를 조회할 수 있는 즉시 호출되는 콜백을 실행 3.3+
app.version Vue.js 버전 확인
app.config 환경 설정이 포함된 구성 객체
app.config.errorHandler 예외 처리되지 않은 에러 발생 시 실행할 핸들러 정의
app.config.warnHandler 경고 발생 시 실행할 핸들러 정의
app.config.performance 개발 모드일 때, 성능 추적 활성화
app.config.compilerOptions 런타임 컴파일러 옵션 설정
app.config.globalProperties 앱 내의 모든 하위 컴포넌트에서 접근할 수 있는 전역 속성을 등록
app.config.optionMergeStrategies 컴포넌트 옵션의 커스텀 병합 함수를 정의
app.config.idPrefix useId()로 생성하는 ID의 접두사를 지정 3.5+
app.config.throwUnhandledErrorInProduction 프로덕션 모드에서도 처리되지 않은 에러를 던지도록 강제 3.5+

# SFC 구조

*.vue 파일은 <template>, <script>, <style> 태그로 구성된 싱글 파일 컴포넌트(Single File Component, SFC)입니다.
*.vue 파일은 모듈 컴파일러를 통해 브라우저에서 사용할 수 있는 자바스크립트 파일로 변환됩니다.
lang 속성을 사용해, <script><style> 태그에서 사용할 언어(타입스크립트, SCSS 등)를 지정할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts"> // 스크립트(JS) </script> <template> <!-- 템플릿(HTML) --> </template> <style scoped lang="scss"> /* 스타일(CSS) */ </style>

# 범위가 지정된 CSS

SFC 컴포넌트 <style> 태그에 scoped 속성을 추가하면, 컴포넌트 내부에서만 일치하는 CSS 선택자(Selector)를 작성할 수 있습니다.
외부 컴포넌트에 영향을 주지 않으므로, 컴포넌트 간의 선택자 이름 충돌을 방지할 수 있습니다.
대부분 scoped 속성 사용을 권장합니다.

VUE
1
2
3
4
5
6
7
8
9
<template> <div></div> </template> <style scoped> div { color: red; } </style>

출력된 결과를 확인하면, 요소와 선택자에 data-v-로 시작하는 속성이 추가되어 있습니다.
이것을 스타일 해시(Style Hash)라고 하며, 이 고유한 속성 이름을 통해 컴포넌트 간의 선택자 이름 충돌을 방지합니다.

HTML
1
2
3
4
5
6
<style> div[data-v-7a7a37b1] { color: red; } </style> <div data-v-7a7a37b1></div>
출력된 결과.

scoped 속성은 이름 충돌을 방지하지만, 간혹 중첩된 하위 컴포넌트에 영향을 줘야 하는 경우가 있습니다.
이때 :deep() 가상 클래스를 사용할 수 있습니다.

/src/components/Parent.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts"> import Child from './components/Child.vue' </script> <template> <div class="parent"> <Child /> .child </div> </template> <style scoped> .parent :deep(.child) { color: red; } </style>
/src/components/Child.vue
VUE
1
2
3
4
5
6
7
<template> <div class="child-wrap"> <div class="child">CHILD</div> </div> </template> <style scoped></style>

scoped 속성을 통한 부모 컴포넌트의 스타일 해시는 자식 컴포넌트의 최상위 요소에 적용됩니다.

HTML
1
2
3
4
5
6
7
<div data-v-7a7a37b1 class="parent"> <div data-v-7a7a37b1 data-v-0904fc8e class="child-wrap"> <div data-v-0904fc8e class="child"> CHILD </div> </div> </div>
출력된 결과.

하나의 컴포넌트(SFC)에서 여러 개의 <style> 태그를 사용할 수 있습니다.

VUE
1
2
3
<style scoped lang="scss"></style> <style scoped></style> <style></style>

# 반응형 데이터

데이터를 화면에 출력하고 있을 때, 기본적으로는 데이터를 변경하면 화면에 반영되지 않습니다.
Vue와 같은 모던 프론트엔드 프레임워크에서, 데이터를 변경하면 화면에 자동으로 반영되는 기능을 '반응성(Reactivity)'이라고 하고, 그 반응성을 가진 데이터를 '반응형 데이터(Reactive state)' 혹은 줄여서 '반응형'이라고 합니다.

# ref vs reactive

ref()reactive() 함수는 모두 반응형 데이터를 반환하지만, 두 함수는 일부 차이가 있습니다.
ref() 함수는 모든 데이터 타입에서 사용할 수 있고,
reactive() 함수는 객체나 배열 같은 참조형 데이터 타입에서 사용해야 반응성을 가집니다.

함수 원시형 반응성 참조형 반응성 .value 여부 반환 암시적 깊은 감시(Watch)
ref() O O O 참조(Ref) 객체 X
reactive() X O X 반응형 데이터 O

주로 ref() 함수를 사용하지만, 매번 .value 속성으로 값에 접근해야 하는 번거로움이 있습니다.
reactive() 함수는 .value 속성을 사용하지 않고도 반응성을 가질 수 있지만, 참조형 데이터 타입에서만 사용할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup> import { ref, reactive } from 'vue' const refPrimitive = ref(123) // 반응성 O const reactivePrimitive = reactive(123) // 반응성 X console.log(refPrimitive.value) // 123 console.log(reactivePrimitive === 123) // true const user = { name: 'HEROPY', age: 85 } const refUser = ref(user) const reactiveUser = reactive(user) console.log(refUser.value.name) // 'HEROPY' console.log(reactiveUser.name) // 'HEROPY' console.log(refUser.value.age) // 85 console.log(reactiveUser.age) // 85 </script>

템플릿에서는 ref() 혹은 reactive() 함수 모두 .value 속성을 사용하지 않습니다.

VUE
1
2
3
4
5
6
7
8
9
10
<template> <div>{{ refPrimitive }}</div> <div>{{ reactivePrimitive }}</div> <div>{{ refUser.name }}</div> <div>{{ reactiveUser.name }}</div> <div>{{ refUser.age }}</div> <div>{{ reactiveUser.age }}</div> </template>

타입스크립트를 사용하면, reactive() 함수에서 원시형 선언 문제를 바로 확인할 수 있습니다.

VUE
1
2
3
4
5
<script setup lang="ts"> import { reactive } from 'vue' const reactivePrimitive = reactive(123) // 'number' 형식의 인수는 'object' 형식의 매개 변수에 할당될 수 없습니다.ts(2345) </script>

# 템플릿 문법

*.vue 컴포넌트의 최상위 <template> 태그(HTML) 안에서 사용하는 문법입니다.

# 데이터 보간

*.vue 컴포넌트의 <script> 태그에서 최상위 변수는 반응성(Reactivity) 여부를 떠나 템플릿에 데이터를 보간할 수 있습니다.
{{ data }}와 같이, 중괄호({}) 기호를 사용해 데이터를 보간하는 패턴을 '이중 중괄호 문법(Mustache)'라고 합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts"> import { ref } from 'vue' const data = 123 // 일반 데이터 const reactiveData = ref(123) // 반응형 데이터 </script> <template> <div>{{ data }}</div> <div>{{ reactiveData }}</div> </template>
이중 중괄호 데이터 보간
HTML
1
2
<div>123</div> <div>123</div>
출력된 결과.

# HTML 출력

문자에 포함된 HTML 코드를 실제 출력하려면, 이중 중괄호가 아닌 v-html 디렉티브를 사용해야 합니다.

v-html, v-bind와 같이 v- 접두사로 시작하는 특수한 속성을 디렉티브(Directive)라고 합니다.

VUE
1
2
3
<script setup lang="ts"> const rawHtml = '<div style="color: red;">HTML 출력</div>' </script>
HTML 코드를 가진 문자열

HTML 코드 문자를 단순히 이중 중괄호 문법으로 출력하면, el.textContent와 같이 문자 그대로 출력합니다.
v-html 디렉티브를 사용하면, el.innerHTML와 같이 출력합니다.

VUE
1
2
3
4
<template> <div>{{ rawHtml }}</div> <div v-html="rawHtml"></div> </template>
VUE
1
2
<div>&lt;div style="color: red;"&gt;HTML 출력&lt;/div&gt;</div> <div style="color: red;">HTML 출력</div>
출력된 결과.

외부 사용자가 작성한 HTML을 동적으로 렌더링하는 것(댓글 시스템 같은)은 XSS 취약점이 발생할 수 있으므로 주의해야 하지만, 신뢰할 수 있는 콘텐츠일 경우 사용에 전혀 문제가 없습니다!

# 속성 바인딩

HTML 속성(Attributes)에 데이터를 연결할 수 있습니다.
v-bind:속성 패턴보다 :속성 패턴(축약형)이 더 짧고 간결하기에 추천됩니다.
만약, 여러 속성을 한 번에 바인딩할 때는 v-bind에 속성 객체를 값으로 사용합니다.

1
2
3
<요소 v-bind:속성="데이터" /> <요소 :속성="데이터" /> <요소 v-bind="속성_객체" />
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> import { ref } from 'vue' const name = ref('hello-world') const isValid = ref(true) const attrs = ref({ id: 'hello-world', disabled: true, }) </script> <template> <!-- 일반 속성 바인딩 --> <div :id="name"></div> <!-- 불린 속성 바인딩 --> <input :disabled="isValid" /> <!-- 여러 속성을 객체로 바인딩 --> <button v-bind="attrs">버튼!</button> </template>
HTML
1
2
3
4
5
<div id="hello-world"></div> <input disabled /> <button id="hello-world" disabled>버튼!</button>
출력된 결과.

:['속성이름'] 패턴을 사용해, 동적으로 속성 이름을 결정할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup lang="ts"> import { ref } from 'vue' const attrName = ref('id') const attrValue = ref('hello') </script> <template> <button @click="attrName = attrName === 'id' ? 'class' : 'id'">Toggle attribute name!</button> <h1 :[attrName]="attrValue">Hello!</h1> </template> <style scoped> #hello { color: red; } .hello { color: blue; } </style>
id와 class 속성을 동적으로 결정

# 표현식 사용

이중 중괄호와 모든 디렉티브에서는 자바스크립트 표현식(Expression)을 사용할 수 있습니다.
예를 들어, const a = 1, function b() {}과 같은 선언문(Declaration)은 사용할 수 없습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts"> import { ref } from 'vue' const name = ref('HEROPY') const age = ref(123) const isValid = ref(true) function add(a: number, b: number) { return a + b } </script> <template> <div>{{ name.split('').reverse().join('') }}</div> <div>{{ age - 100 }}</div> <div>{{ isValid ? 'YES' : 'NO' }}</div> <div :id="`hello-${name}`">{{ add(age, 85) }}</div> </template>

# HTML 클래스 바인딩

객체와 배열 데이터로 바인딩해 HTML 클래스 선택자를 동적으로 추가/제거할 수 있습니다.
일반적으로 객체 리터럴(Literal) 방식의 바인딩을 사용합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<script setup lang="ts"> import { ref } from 'vue' const isActive = ref(true) const error = ref(false) const status = ref('primary') const cssClasses = ref({ active: true, error: false }) </script> <template> <!-- 객체로 바인딩 --> <div class="btn" :class="{ active: isActive, error, [`btn-${status}`]: true, }"></div> <!-- 배열로 바인딩 --> <div class="btn" :class="[ isActive ? 'active' : '', error ? 'error' : '', `btn-${status}`, ]"></div> <!-- 객체와 배열을 함께 사용 --> <div class="btn" :class="[ cssClasses, `btn-${status}`, ]"></div> </template>
HTML
1
2
3
<div class="btn active btn-primary"></div> <div class="btn active btn-primary"></div> <div class="btn active btn-primary"></div>
출력된 결과.

# 인라인 스타일 바인딩

객체와 배열 데이터로 바인딩해 CSS 속성과 값을 동적으로 추가/제거할 수 있습니다.
font-size, background-color와 2개 단어 이상의 속성 이름을 카멜 케이스(Camel Case)로 작성하면 자동으로 케밥 케이스(Kebab Case)로 변환됩니다.
숫자 데이터를 입력할 때는, 단위를 함께 작성해야 합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup lang="ts"> import { ref } from 'vue' const color = ref('red') const size = ref(16) const colorStyles = { color: 'red', backgroundColor: 'blue', } </script> <template> <!-- 객체로 바인딩 --> <div :style="{ color, fontSize: `${size}px`, backgroundColor: colorStyles.backgroundColor }"></div> <!-- 배열로 바인딩 --> <!-- 여러 스타일 객체를 사용하기 위한 패턴 --> <div :style="[ colorStyles, { fontSize: `${size}px` }, ]"></div> </template>
HTML
1
2
<div style="color: red; font-size: 16px; background-color: blue;"></div> <div style="color: red; font-size: 16px; background-color: blue;"></div>
출력된 결과.

# 요소 참조

요소에 직접 접근해야 하는 경우, 요소의 ref 속성에 이름을 지정하고 useTemplateRef() 함수로 그 이름을 조회합니다.
참조는 컴포넌트가 연결(Mount)된 후에 채워지므로, onMounted() 훅 안에서 접근합니다.

useTemplateRef() 함수는 3.5+에서 사용할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> import { useTemplateRef, onMounted } from 'vue' const inputRef = useTemplateRef('my-input') onMounted(() => { inputRef.value?.focus() }) </script> <template> <input ref="my-input" /> </template>

useTemplateRef()의 인수는 찾으려는 요소의 ref 속성에 지정한 문자입니다.
반대로 인수와 ref 속성의 값이 다르면 요소를 찾지 못하고 참조는 null이 됩니다.

다음과 같이 여러 요소를 참조할 수도 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts"> import { ref, useTemplateRef, onMounted } from 'vue' const fruits = ref(['Apple', 'Banana', 'Orange']) const liRefs = useTemplateRef('items') onMounted(() => { liRefs.value?.forEach(liEl => { console.log(liEl.textContent) }) }) </script> <template> <ul> <li v-for="fruit in fruits" ref="items" :key="fruit"> {{ fruit }} </li> </ul> </template>

참조 배열의 순서는 원본 배열의 순서와 일치하는 것이 보장되지 않습니다.
특정 요소를 찾아야 한다면 인덱스 대신 요소의 데이터 속성이나 별도의 조건으로 구분해야 합니다.

useTemplateRef() 대신 ref를 사용할 수도 있습니다.
단, 제네릭으로 참조할 요소의 타입을 명시해야 합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup lang="ts"> import { ref, onMounted } from 'vue' const inputRef = ref<HTMLInputElement | null>(null) const fruits = ref(['Apple', 'Banana', 'Orange']) const liRefs = ref<HTMLLIElement[]>([]) onMounted(() => { inputRef.value?.focus() liRefs.value?.forEach(liEl => { console.log(liEl.textContent) }) }) </script> <template> <input ref="inputRef" /> <ul> <li v-for="fruit in fruits" ref="items" :key="fruit"> {{ fruit }} </li> </ul> </template>
3.5 이전 버전의 요소 참조

# 조건부 렌더링

v-if, v-else-if, v-else 디렉티브를 사용해 데이터 조건에 맞게 템플릿을 렌더링할 수 있습니다.
각 디렉티브는 연결된 형제 요소에만 사용할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup lang="ts"> import { ref } from 'vue' const isShow = ref(true) const list = ref([]) </script> <template> <!-- v-if --> <div v-if="isShow"></div> <div v-if="isShow && list.length"></div> <div v-if="isShow || list.length"></div> <!-- v-else --> <div v-if="isShow"></div> <div v-else></div> <!-- v-else-if --> <div v-if="isShow"></div> <div v-else-if="list.length"></div> <div v-else></div> </template>

v-if, v-else-if, v-else 디렉티브는 단일 요소에 사용해야 하지만, 다중 요소의 조건부 렌더링이 필요하면, <template> 태그를 사용합니다.

VUE
1
2
3
4
5
<script setup lang="ts"> import { ref } from 'vue' const isShow = ref(true) </script>

렌더링할 요소 모두에 조건을 추가하는 방법은 권장하지 않습니다.

VUE
1
2
3
4
5
<template> <h1 v-if="isShow"></h1> <div v-if="isShow"></div> <p v-if="isShow"></p> </template>

랩핑 요소를 추가하면 간단하지만, 불필요한 상위 요소(<div>)를 가지게 됩니다.

VUE
1
2
3
4
5
6
7
<template> <div v-if="isShow"> <h1></h1> <div></div> <p></p> </div> </template>

<template> 태그를 사용하면, 불필요한 상위 요소가 없어도 다중 요소에 한 번에 조건을 추가할 수 있습니다.
Vue에서 <template> 태그는 최종 렌더링에 포함되지 않습니다.

VUE
1
2
3
4
5
<template v-if="isShow"> <h1></h1> <div></div> <p></p> </template>

# 리스트 렌더링

기본적으로 v-for 디렉티브를 사용하면, 배열 데이터의 아이템 개수에 맞게 반복 렌더링을 할 수 있습니다.
:key 속성은 반복되는 각 아이템을 Vue.js가 구분하는 기준이므로, 고유한 문자나 숫자의 원시형 데이터가 꼭 필요합니다.
객체나 배열 등의 참조형 데이터를 :key 속성에 지정하지 않도록 주의해야 합니다.

'아이템 변수'의 유효범위(Scope)는 해당 요소로 제한됩니다.

VUE
1
2
3
4
5
6
7
8
9
<div v-for="아이템_변수 in 배열_데이터" :key="고유한_원시형"> </div> <div v-for="(아이템_변수, 인덱스) in 배열_데이터" :key="고유한_원시형"> </div>
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts"> import { ref } from 'vue' const users = ref([ { name: 'Neo', age: 85 }, { name: 'Lewis', age: 22 }, { name: 'Evan', age: 46 } ]) </script> <template> <div v-for="(user, index) in users" :key="user.name" :data-user-name="user.name" :data-user-age="user.age"> {{ index + 1 }}) {{ user.name }}({{ user.age }}) </div> </template>
HTML
1
2
3
<div data-user-name="Neo" data-user-age="85"> 1) Neo(85) </div> <div data-user-name="Lewis" data-user-age="22"> 2) Lewis(22) </div> <div data-user-name="Evan" data-user-age="46"> 3) Evan(46) </div>
출력된 결과.

# 객체 데이터 반복

v-for 디렉티브를 사용해, Key-Value(속성-값) 형태의 객체 데이터도 반복적으로 렌더링할 수 있습니다.
위에서 살펴본 '아이템 변수'처럼 , 속성, 인덱스 모두 원하는 이름을 사용할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div v-for="in 객체_데이터" :key="고유한_원시형"> </div> <div v-for="(값, 속성) in 객체_데이터" :key="고유한_원시형"> </div> <div v-for="(값, 속성, 인덱스) in 객체_데이터" :key="고유한_원시형"> </div>
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup lang="ts"> import { ref } from 'vue' const userInfo = ref({ name: 'Neo', age: 85, emails: ['thesecon@gmail.com', 'neo@zillinks.com'] }) </script> <template> <div v-for="(value, key, index) in userInfo" :key="key"> <template v-if="Array.isArray(value)"> {{ index + 1 }}) {{ key }}: {{ value.join(', ') }} </template> <template v-else> {{ index + 1 }}) {{ key }}: {{ value }} </template> </div> </template>
HTML
1
2
3
<div>1) name: Neo</div> <div>2) age: 85</div> <div>3) emails: thesecon@gmail.com, neo@zillinks.com</div>
출력된 결과.

# 숫자 범위로 반복

v-for 디렉티브에서는 정수를 사용해 요소를 반복 출력할 수 있습니다.
다음 예제에서 아이템 변수 n은 0이 아닌 1부터 시작합니다.

VUE
1
2
3
4
5
6
7
<template> <div v-for="n in 4" :key="n"> {{ n }} </div> </template>
HTML
1
2
3
4
<div>1</div> <div>2</div> <div>3</div> <div>4</div>
출력된 결과.

# v-if 와 v-for

요소에 v-ifv-for(리스트 렌더링) 디렉티브를 함께 사용하면, v-if가 먼저 평가됩니다.
단일 요소에서 사용하는 두 디렉티브는 기능적으로 충돌하니, 처음부터 단일 요소에서 같이 사용하지 않도록 주의하세요!

VUE
1
2
3
4
5
6
<script setup lang="ts"> import { ref } from 'vue' const isShow = ref(true) const list = ref([1, 2, 3]) </script>

다음의 두 방식 모두 출력된 결과는 같습니다.

VUE
1
2
3
4
5
6
7
8
9
<!-- 권장하지 않는 방법 --> <template> <div v-if="isShow" v-for="item in list" :key="item"> {{ item }} </div> </template>
VUE
1
2
3
4
5
6
7
8
<!-- 권장하는 방법 --> <template v-if="isShow"> <div v-for="item in list" :key="item"> {{ item }} </div> </template>

조건에 따라 일부 요소만 반복 출력(필터링)하는 경우, 두 디렉티브를 단일 요소에서 사용하지 않기 위해 v-for 디렉티브를 <template> 태그에서 사용할 수도 있습니다.
이 때는 key 속성도 <template> 태그에 같이 작성합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts"> import { ref } from 'vue' const list = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) </script> <template> <ul> <template v-for="item in list" :key="item"> <li v-if="item > 3 && item < 8"> {{ item }} </li> </template> </ul> </template>
HTML
1
2
3
4
5
6
<ul> <li>4</li> <li>5</li> <li>6</li> <li>7</li> </ul>
출력된 결과.

# 이벤트 핸들링

요소에 v-on:이벤트 패턴을 이용해 이벤트 핸들러를 등록할 수 있습니다.
v-on:이벤트 패턴보다 @이벤트 패턴(축약형)이 더 짧고 간결하기에 추천됩니다.
간단한 표현식은 인라인 핸들러 방식으로 작성할 수 있습니다.
혹은 이미 정의된 함수를 연결하는 방식(메소드 핸들러)으로도 작성할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> import { ref } from 'vue' const count = ref(0) function handler() { count.value += 1 } </script> <template> <h1>{{ count }}</h1> <button @click="count += 1">증가!</button> <!-- 인라인 핸들러 --> <button @click="handler">증가!</button> <!-- 메소드 핸들러 --> </template>

인라인 핸들러에서 메소드를 호출할 때, 인수로 이벤트 객체($event)를 전달할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script setup lang="ts"> import { ref } from 'vue' const urls = ref([ 'heropy.dev', 'github.com', 'google.com', ]) function handler(name: string, event: MouseEvent) { event.preventDefault() location.href = `https://${name}` } </script> <template> <a v-for="url in urls" :key="url" :href="`https://${url}`" @click="handler(url, $event)"> {{ url }} </a> </template>

, 혹은 ; 기호로 구분해 하나의 이벤트에 여러 개의 핸들러를 등록할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts"> import { ref } from 'vue' const count = ref(0) function increase() { count.value += 1 } function log(event: MouseEvent) { console.log(event) } </script> <template> <h1>{{ count }}</h1> <button @click="increase(), log($event)">증가!</button> <button @click="increase(); log($event)">증가!</button> </template>

# 수식어

이벤트 핸들러에 수식어(Modifier)를 사용하면, 추가적인 동작을 부여할 수 있습니다.
각 수식어는 체이닝(Chaining) 형태로 사용할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template> <a @click.stop="handler">클릭!</a> <input @keydown.stop="handler" /> <!-- 핸들러 없이 수식어만 사용 --> <a @click.stop>클릭!</a> <input @keydown.stop /> <!-- 수식어 체이닝 --> <a @click.stop.prevent="handler">클릭!</a> <input @keydown.stop.enter="handler" /> <!-- 정확한 키 조합 --> <a @click.ctrl.exact="handler">클릭!</a> <input @keydown.alt.enter.exact="handler" /> </template>

# 이벤트 수식어

수식어 설명 비고
.prevent 기본 동작 중지 event.preventDefault()
.stop 이벤트 전파 중지 event.stopPropagation()
.capture 캡처링 모드로 이벤트 핸들러 등록 { capture: true }
.once 한 번만 실행되는 핸들러 등록 { once: true }
.passive 화면 동작과 로직의 분리를 통한 체감 성능 향상 { passive: true }
.self 이벤트가 등록된 요소와 발생한 요소가 일치하는 경우만 핸들러 호출 if (target === currentTarget)

# 키 수식어

수식어 설명 비고
.KEY-NAME 키보드의 kebab-case 키 이름
.exact 정확한 키 조합
.enter Enter
.tab Tab
.delete Backspace, Delete
.esc Escape
.space [공백]
.up ArrowUp
.down ArrowDown
.left ArrowLeft
.right ArrowRight
.ctrl Control
.alt Alt
.shift Shift
.meta Meta Cmd 혹은 Win

# 마우스 수식어

수식어 설명 비고
.left 마우스 좌측 버튼 클릭 click
.right 마우스 우측 버튼 클릭 contextmenu
.middle 마우스 휠 버튼 클릭 auxclick

# 양식 입력 바인딩

<input>, <textarea>, <select>와 같은 HTML 양식 요소에서는 속성 바인딩과 이벤트 핸들링을 통해 양방향 데이터 바인딩을 할 수 있습니다.
그리고 v-model 디렉티브를 사용하면, 더욱 간결하게 양방향 데이터 바인딩을 할 수 있습니다.

자판의 키보다 글자 수가 많은 한글(CJK 문자)은, IME(Input Method Editor) 입력기가 여러 번의 키 입력을 한 글자로 조합하고, 이렇게 조합이 진행되는 상태를 구성(Composition)이라고 합니다.
v-model 디렉티브는 IME 구성 중(Composing)에는 데이터를 갱신하지 않기 때문에, 한글을 입력할 때 한 글자씩 지연됩니다.
구성 중인 글자도 즉시 반영해야 한다면, v-model 디렉티브 대신 속성과 이벤트 핸들링을 통합 데이터 바인딩을 사용해야 합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> import { ref } from 'vue' const text = ref('') </script> <template> <!-- 양방향 데이터 바인딩 --> <input :value="text" @input="text = ($event.target as HTMLInputElement).value" /> <!-- v-model 디렉티브를 통한 양방향 데이터 바인딩 --> <input v-model="text" /> </template>

축약형의 v-model 디렉티브를 사용하지 않는 경우, 각 요소는 다음과 같은 속성과 이벤트를 사용합니다.

요소 사용 속성 사용 이벤트 비고
<input type="text" /> :value @input
<textarea> :value @input
<input type="checkbox" /> :checked @change
<input type="radio" /> :checked @change
<select> :value @change

# 요소별 바인딩 패턴

# 텍스트

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> import { ref } from 'vue' const text = ref('') </script> <template> <!-- 일반적인 경우, --> <input v-model="text" /> <!-- 한글이 필요한 경우, --> <input :value="text" @input="text = ($event.target as HTMLInputElement)?.value" /> <h1>{{ text }}</h1> </template>

# 여러 줄 텍스트

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> import { ref } from 'vue' const text = ref('') </script> <template> <!-- 일반적인 경우, --> <textarea v-model="text"></textarea> <!-- 한글이 필요한 경우, --> <textarea :value="text" @input="text = ($event.target as HTMLTextAreaElement)?.value"></textarea> <pre>{{ text }}</pre> </template>

# 단일 체크박스

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> import { ref } from 'vue' const checked = ref(false) </script> <template> <label> <input v-model="checked" type="checkbox" /> 동의합니다. </label> <h2>동의 여부: {{ checked ? '예' : '아니오' }}</h2> </template>

# 다중 체크박스

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<script setup lang="ts"> import { ref } from 'vue' interface Fruit { text: string value: string } const fruits = ref<Fruit[]>([ { text: '사과', value: 'apple' }, { text: '바나나', value: 'banana' }, { text: '체리', value: 'cherry' } ]) const checked = ref<string[]>([]) function setChecked(event: Event, fruit: Fruit) { checked.value = (event.target as HTMLInputElement)?.checked ? checked.value.concat([fruit.value]) : checked.value.filter(v => v !== fruit.value) } </script> <template> <label v-for="fruit in fruits" :key="fruit.value"> <input :checked="checked.includes(fruit.value)" :value="fruit.value" type="checkbox" @change="setChecked($event, fruit)" /> {{ fruit.text }} </label> <h2>선택된 과일: {{ checked.join(', ') }}</h2> </template>

# 라디오 버튼

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup lang="ts"> import { ref } from 'vue' interface Fruit { text: string value: string } const fruits = ref<Fruit[]>([ { text: '사과', value: 'apple' }, { text: '바나나', value: 'banana' }, { text: '체리', value: 'cherry' } ]) const picked = ref('') </script> <template> <label v-for="fruit in fruits" :key="fruit.value"> <input v-model="picked" type="radio" :value="fruit.value" /> {{ fruit.text }} </label> <h2>선택된 과일: {{ picked }}</h2> </template>

# 단일 선택

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<script setup lang="ts"> import { ref } from 'vue' interface Fruit { text: string value: string } const fruits = ref<Fruit[]>([ { text: '사과', value: 'apple' }, { text: '바나나', value: 'banana' }, { text: '체리', value: 'cherry' } ]) const selected = ref('') </script> <template> <select v-model="selected"> <option value="" disabled> 가장 좋아하는 과일을 선택하세요. </option> <option v-for="fruit in fruits" :key="fruit.value" :value="fruit.value"> {{ fruit.text }} </option> </select> <h2>선택된 과일: {{ selected }}</h2> </template>

# 다중 선택

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup lang="ts"> import { ref } from 'vue' const fruits = ref([ { text: '사과', value: 'apple' }, { text: '바나나', value: 'banana' }, { text: '체리', value: 'cherry' } ]) const selected = ref<string[]>([]) </script> <template> <select v-model="selected" multiple> <option value="" disabled> 가장 좋아하는 과일을 선택하세요. </option> <option v-for="fruit in fruits" :key="fruit.value" :value="fruit.value"> {{ fruit.text }} </option> </select> <h2>선택된 과일: {{ selected }}</h2> </template>

# 편집 가능 요소

다음은 HTML contenteditable 속성을 사용하는 요소의 예제입니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> import { ref, useTemplateRef, watch } from 'vue' const text = ref('') const inputRef = useTemplateRef('input') // text 갱신과 inputRef 내용을 동기화 watch(text, newValue => { if (inputRef.value && inputRef.value.innerText !== newValue) { inputRef.value.innerText = newValue } }) </script> <template> <div ref="input" contenteditable @input="text = ($event.target as HTMLDivElement).innerText"></div> <pre>{{ text }}</pre> </template>

# 수식어

입력 요소의 v-model 디렉티브에 수식어(Modifier)를 사용하면, 추가적인 동작을 부여할 수 있습니다.

수식어 설명 비고
.lazy 입력이 완료된 후(Enter, Tab 등) 데이터를 업데이트 @change
.number 숫자 데이터 타입으로 변환 type="number"
.trim 입력된 텍스트의 앞뒤 공백을 제거 text.trim()
VUE
1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts"> import { ref } from 'vue' const text = ref('') const count = ref(0) </script> <template> <input v-model.lazy="text" /> <input v-model.number="count" /> <input v-model.trim="text" /> </template>

# 계산된 데이터

Getter를 사용해 반응형 데이터를 기준으로 새롭게 계산된 데이터(Computed)를 만들 수 있습니다.

Getter(게터)는 데이터를 읽을 때(Get), 호출되는 함수를 말합니다.
Setter(세터)는 데이터를 지정할 때(Set), 호출되는 함수를 말합니다.

비동기 요청이나 DOM 변경은 계산된 데이터(Getter)를 사용하지 않아야 합니다.
필요한 경우, 감시자(Watcher)를 사용해야 합니다!

TS
1
const 계산된_데이터 = computed(게터)
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts"> import { ref, computed } from 'vue' const count = ref(0) const double = computed(() => count.value * 2) function increase() { count.value += 1 } </script> <template> <div>{{ count }}</div> <div>{{ double }}</div> <button @click="increase">증가!</button> </template>

Getter는 읽기 전용이므로, 데이터를 지정할 때 호출할 Setter를 추가할 수 있습니다.
computed()의 인수로 Getter 대신, getset 속성을 포함한 객체를 전달합니다.

TS
1
2
3
4
const 계산된_데이터 = computed({ get: 게터, set: 세터 })
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> import { ref, computed } from 'vue' const count = ref(0) const double = computed({ get: () => count.value * 2, set: val => { count.value = val / 2 } }) function increase() { double.value += 1 } </script> <template> <div>{{ count }}</div> <div>{{ double }}</div> <button @click="increase">증가!</button> </template>

# 데이터 감시

함수 설명 즉시 실행 이전값 확인 깊은 감시 옵션
watch() 명시적 반응형 데이터 감시 X O 명시적, 암시적 모두 가능 immediate, deep, once, flush
watchEffect() 콜백 모든 반응형 데이터 감시 O X 명시적만 가능 flush

각 옵션은 다음과 같습니다.

옵션 설명 기본값 비고
immediate 감시 시작과 함께 콜백을 한 번 호출 false
deep 중첩 속성까지 탐색해 깊게 감시 false
deep: 숫자 탐색할 최대 깊이를 지정 3.5+
once 콜백을 한 번만 호출하고 감시 종료 false 3.4+
flush 콜백 호출 시점 pre

flush 옵션은 다음과 같은 값을 가질 수 있습니다.

설명 기본값 별칭
pre 상태 변경 시, 화면 갱신 전 콜백 호출 O
post 상태 변경 시, 화면 갱신 후 콜백 호출 watchPostEffect()
sync 여러 번 상태 변경 시에도 버퍼링 없이 동일 Tick에서 같은 횟수로 콜백 호출 watchSyncEffect()

# 감시 패턴

# 단일 데이터 감시

watch() 함수는 이전값(oldVal)을 확인할 수 있지만, watchEffect() 함수는 이전값을 확인할 수 없습니다.
watchEffect() 함수는 별도 옵션이 없이도 즉시 실행되기 때문에 처음부터 이전값(1)이 콘솔에 출력되고 버튼을 클릭하면 새로운 값(7)이 출력됩니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts"> import { ref, watch, watchEffect } from 'vue' const count = ref(1) watch(count, (newVal, oldVal) => { console.log(newVal, oldVal) // 7, 1 }) watchEffect(() => { console.log(count.value) // 1 // 7 }) </script> <template> <!-- 버튼 클릭! --> <button @click="count = 7">새로운 값 할당!</button> </template>

# 다중 데이터 감시

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> import { ref, watch, watchEffect } from 'vue' const count = ref(1) const double = ref(2) watch([count, double], ([newCount, newDouble], [oldCount, oldDouble]) => { console.log(newCount, oldCount) // 7, 1 console.log(newDouble, oldDouble) // 14, 2 }) watchEffect(() => { console.log(count.value) // 1 // 7 console.log(double.value) // 2 // 14 }) function assignValue() { count.value = 7 double.value = 14 } </script>

# 깊은 감시

ref() 함수로 생성한 반응형 데이터는 암시적으로 깊은 감시를 하지 않기 때문에, 필요한 경우 watch() 함수의 deep 옵션을 사용해야 합니다.
reactive() 함수로 생성한 반응형 데이터는 암시적으로 깊은 감시를 하므로, watch() 함수로 감시할 때 deep 옵션을 사용하지 않아도 됩니다.

암시적 깊은 감시는 대상의 모든 중첩 속성을 탐색하므로, 성능에 좋지 않은 영향을 줄 수 있습니다.
3.5+에서는 deep 옵션에 숫자를 지정해 탐색할 최대 깊이를 제한할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<script setup lang="ts"> import { ref, watch, watchEffect } from 'vue' const user = ref({ name: 'HEROPY', address: { city: 'Busan' } }) watch(user, newVal => { console.log(newVal) // 반응 없음! }) watch( user, newVal => { console.log(newVal) // user 객체 }, { deep: true // 암시적 깊은 감시 } ) watch( () => user.value.address.city, // 명시적 깊은 감시 newVal => { console.log(newVal) // 'Seoul' } ) watchEffect(() => { console.log(user.value.address.city) // 'Busan' // 'Seoul' }) </script> <template> <!-- 버튼 클릭! --> <button @click="user.address.city = 'Seoul'">새로운 값 할당!</button> </template>

deep 옵션에 숫자를 지정하면, 지정한 깊이까지만 탐색합니다.
깊이는 ref()가 감싼 객체의 직속 속성부터 1단계로 셉니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup lang="ts"> import { ref, watch } from 'vue' const user = ref({ name: 'HEROPY', address: { city: 'Seoul' } }) watch( user, newVal => { console.log(newVal) // 이름 변경에만 반응! }, { deep: 1 // user의 속성까지 탐색 } ) </script> <template> <!-- 버튼 클릭! --> <button @click="user.name += '!'">이름 변경!</button> <button @click="user.address.city += '!'">도시 변경!</button> <button @click="user.address = { city: 'Jeju' }">주소 변경!</button> </template>

위 예제에서 deep: 1user.address가 통째로 교체될 때는 반응하지만, user.address.city 변경에는 반응하지 않습니다.

# 감시 종료

각 감시 함수의 반환된 값(함수)을 호출하면, 감시를 종료할 수 있습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts"> import { ref, watch, watchEffect } from 'vue' const count = ref(0) const unwatch = watch(count, (newVal, oldVal) => { console.log('watch:', newVal, oldVal) }) const unwatchEffect = watchEffect(() => { console.log('watchEffect:', count.value) }) </script> <template> <button @click="count += 1">새로운 값 할당!</button> <button @click="unwatch">watch() 감시 종료</button> <button @click="unwatchEffect">watchEffect() 감시 종료</button> </template>

# 정리 콜백

콜백에서 비동기 요청이나 타이머 같은 작업을 시작하면, 다음 감시가 발생할 때 이전 작업을 취소해야 합니다.
onWatcherCleanup() 함수로 정리(Cleanup) 콜백을 등록하면, 감시가 무효화되어 다시 실행되기 직전과 감시가 종료될 때 호출됩니다.

onWatcherCleanup() 함수는 3.5+에서 사용할 수 있습니다.
그리고 await 이후가 아닌, 콜백이 동기적으로 실행되는 시점에 호출해야 합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> import { ref, watch, onWatcherCleanup } from 'vue' const userId = ref(1) watch(userId, newVal => { const controller = new AbortController() fetch(`https://jsonplaceholder.typicode.com/users/${newVal}`, { signal: controller.signal }) onWatcherCleanup(() => { controller.abort() // 이전 요청 취소! }) }) </script> <template> <button @click="userId += 1">다음 사용자!</button> </template>

# computed와 watch

computed()watch()는 모두 반응형 데이터의 변화에 반응하지만 목적이 다릅니다.
computed()는 다른 데이터로부터 새로운 값을 만들고, watch()는 데이터가 바뀐 뒤에 실행할 동작을 등록합니다.

기준 computed() watch()
목적 값 만들기 변화에 반응해 동작 실행
반환 계산된 값(Ref) 감시 종료 함수
캐싱 O, 의존하는 데이터가 바뀔 때만 재계산 X
감시 대상 게터에서 사용한 데이터를 자동 추적 첫 번째 인수로 명시
비동기 작업 사용하지 않음 사용 가능

결과가 화면에 표시할 값인지 여부로 판단하면 쉽습니다.
템플릿에서 사용할 값을 만드는 것이면 computed()이고, 로컬 스토리지 저장이나 데이터 요청처럼 값 바깥에서 일어나는 일이면 watch()를 사용하는 게 좋습니다.

다음은 watch()로 파생 값을 만드는 흔한 형태입니다.
동작하기는 하지만 상태가 두 개로 늘어나고, 두 값이 어긋나지 않도록 유지하는 책임이 사람에게 넘어옵니다.
price를 바꾸는 코드가 여러 곳에 생기면 그중 하나만 놓쳐도 화면의 값이 어긋납니다.

VUE
1
2
3
4
5
6
7
8
9
10
<script setup lang="ts"> import { ref, watch } from 'vue' const price = ref(1000) const formattedPrice = ref('') watch(price, newVal => { formattedPrice.value = newVal.toLocaleString() }, { immediate: true }) </script>

computed()를 사용하면 상태는 하나로 유지되고, price가 바뀌면 값이 따라옵니다.
immediate 옵션으로 초깃값을 채우는 처리도 필요 없습니다.

VUE
1
2
3
4
5
6
<script setup lang="ts"> import { ref, computed } from 'vue' const price = ref(1000) const formattedPrice = computed(() => price.value.toLocaleString()) </script>

반대로 값을 만드는 데 비동기 작업이 필요하면 watch()를 사용합니다.
게터는 값을 읽는 시점에 호출되므로, 그 안에서 데이터를 요청하면 요청이 언제 몇 번 발생할지 예측할 수 없습니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts"> import { ref, watch } from 'vue' const userId = ref(1) const userName = ref('') watch(userId, async newVal => { const res = await fetch(`https://jsonplaceholder.typicode.com/users/${newVal}`) const user = await res.json() userName.value = user.name }) </script>

# 생명주기 훅

생명주기 훅(Lifecycle Hook)은 컴포넌트의 각 생명주기에서 호출되는 함수를 말합니다.
Options API에서는 대부분 createdmounted 훅을 사용하지만, Composition API에는 created(beforeCreate) 훅이 없습니다.

설명 비고
onMounted() 컴포넌트가 연결된 후
onUpdated() 컴포넌트가 화면을 갱신한 후
onUnmounted() 컴포넌트가 해제된 후
onBeforeMount() 컴포넌트가 연결되기 전
onBeforeUpdate() 컴포넌트가 화면을 갱신하기 전
onBeforeUnmount() 컴포넌트가 해제되기 전
onErrorCaptured() 에러가 캡쳐된 후
onActivated() <KeepAlive>로 캐싱된 컴포넌트가 출력된 후 No SSR
onDeactivated() <KeepAlive>로 캐싱된 컴포넌트가 제거된 후 No SSR
onServerPrefetch() 컴포넌트가 서버 측에서 렌더링되기 전에 해결할 비동기 함수를 등록 SSR only
onRenderTracked() 컴포넌트 렌더링이 반응형 데이터를 추적한 후 Dev only
onRenderTriggered() 반응형 데이터의 변경으로 컴포넌트 리렌더링이 발생할 때 Dev only
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> import { useTemplateRef, onMounted } from 'vue' const inputRef = useTemplateRef('input') onMounted(() => { inputRef.value?.focus() }) </script> <template> <input ref="input" /> </template>
생명주기 훅 사용 예제.

# 컴포저블

Vue 로직을 캡슐화하고 재활용하기 위한 컴포저블(Composable)이라는 함수를 정의할 수 있습니다.
use 접두사를 이용해 함수 이름을 정의하며, React의 Custom Hooks와 비슷한 개념으로 이해할 수 있습니다.

TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { ref } from 'vue' export function useCounter(initialCount = 0) { const count = ref(initialCount) function increase() { count.value += 1 } function decrease() { count.value -= 1 } return { count, increase, decrease } }

컴포저블은 서로 간섭하지 않는 독립적인 상태를 유지하기에 효율적입니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts"> import { useCounter } from '@/composables/useCounter' const counter1 = useCounter() const counter2 = useCounter(7) </script> <template> <div @click="counter1.increase">{{ counter1.count }}</div> <div @click="counter2.decrease">{{ counter2.count }}</div> </template>

직접 정의하는 함수뿐 아니라, Vue가 기본으로 제공하는 함수들도 같은 명명 규칙을 따릅니다.
앞서 살펴본 useTemplateRef()가 대표적입니다.

컴포저블 용도
useTemplateRef() ref 속성으로 이름을 지정한 요소 참조 (3.5+)
useId() 접근성 속성이나 양식 요소에 사용할 고유 ID 생성 (3.5+)
useAttrs() 부모가 전달한 폴스루 속성 조회
useSlots() 부모가 전달한 슬롯 조회

# 컴포넌트

# 등록

*.vue 컴포넌트는 필요한 경우, 다음과 같이 전역으로 등록해 어디서나 사용할 수 있습니다.

전역 등록된 컴포넌트는 트리쉐이킹(Tree Shaking)을 방지하므로, 등록 후 전혀 사용하지 않아도 최종 번들에는 포함됩니다.
그래서 자주 사용하는 등, 꼭 필요한 경우에만 전역으로 등록하는 것이 좋습니다.

/src/components/TheButton.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> defineProps<{ color: string }>() </script> <template> <button :style="{ backgroundColor: color }" class="btn"> <slot></slot> </button> </template>
/src/main.ts
TS
1
2
3
4
5
6
7
import { createApp } from 'vue' import App from './App.vue' import TheButton from './components/TheButton.vue' createApp(App) .component('TheButton', TheButton) // 전역 컴포넌트 등록! .mount('#app')
/src/App.vue
VUE
1
2
3
<template> <TheButton color="red">버튼!</TheButton> </template>
별도의 가져오기 없이 바로 컴포넌트를 사용할 수 있습니다.

컴포넌트를 지역적으로 사용하면, 효율적으로 번들(Bundle)되기에 권장됩니다.
Composition API에서는 (부모) 컴포넌트에서 가져오기(Import) 후 별도 등록 없이 바로 템플릿에서 사용할 수 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
<script setup lang="ts"> import TheButton from './components/TheButton.vue' </script> <template> <TheButton color="red">버튼!</TheButton> </template>

# Props

부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달할 때, Props 방식을 사용합니다.
defineProps()는 컴파일러 매크로(Macro)이므로, 별도 가져오기 없이 바로 사용할 수 있습니다.

스크립트에서는, defineProps()에서 반환된 props 객체로 각 Prop에 접근할 수 있습니다.
템플릿에서는, props 객체 없이 바로 각 Prop에 접근할 수 있습니다.
따라서 반응형 데이터의 이름을 각 Prop과 중복하지 않아야 합니다.

템플릿에서 $props 객체로도 접근할 수 있지만, 이름만 사용하는 방식이 더 간결하므로 권장하지 않습니다.
$props와 같이 $ 접두사를 가진 객체는 컴포넌트의 내장 속성(Built-in Property)입니다.

TS
1
2
3
4
interface Props { // Prop 타입.. } const props = defineProps<Props>()

Prop에 기본값이 필요하면, defineProps()의 반환값을 구조 분해하면서 자바스크립트의 기본값 문법을 그대로 사용합니다.
이를 반응형 Props 구조 분해(Reactive Props Destructure)라고 하며, 구조 분해한 변수도 반응성을 유지합니다.

/src/components/TextField.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts"> const { myValue, backgroundColor = 'yellow' } = defineProps<{ myValue: string backgroundColor?: string // 선택적 Prop }>() console.log(myValue, backgroundColor) </script> <template> <input :style="{ backgroundColor }" :value="myValue" /> </template>

반응형 Props 구조 분해는 3.5+에서 기본 활성화됩니다.
그 이전 버전에서는 구조 분해한 변수가 반응성을 잃기 때문에, withDefaults() 매크로로 기본값을 지정해야 합니다.

withDefaults()defineProps() 호출을 직접 감싸야 합니다.
그리고 배열이나 객체 같은 참조형 기본값은, 컴포넌트 인스턴스마다 별도의 값을 가지도록 함수로 감싸서 반환해야 합니다.

/src/components/TextField.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> const props = withDefaults( defineProps<{ myValue: string backgroundColor?: string tags?: string[] }>(), { backgroundColor: 'yellow', tags: () => [] // 참조형 기본값은 함수로! } ) console.log(props) </script> <template> <input :style="{ backgroundColor }" :value="myValue" /> </template>
3.5 이전 버전의 Props 기본값.

부모 컴포넌트에서 Props를 전달할 때는, HTML 속성 표기에 맞춰 kebab-case를 사용하는 것이 관례입니다.
SFC 템플릿에서는 camelCase도 동작하지만, in-DOM 템플릿에서는 kebab-case만 사용할 수 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts"> import { ref } from 'vue' import TextField from './components/TextField.vue' const text = ref('Hello World!') </script> <template> <TextField :my-value="text" background-color="royalblue" /> </template>

# 컴파일러 매크로

컴파일러 매크로는 실행되는 함수가 아니라, 컴파일 단계에서 다른 코드로 바뀌어 사라지는 선언입니다.

VUE
1
2
3
<script setup lang="ts"> const props = defineProps<{ title: string }>() </script>
컴파일 전.
JS
1
2
3
4
5
6
7
8
9
// // defineProps() 호출은 남지 않습니다. (일부 정보 생략) export default { props: { title: { type: String, required: true } }, setup(__props) { const props = __props } }
컴파일 후.

defineEmits(), withDefaults(), defineModel(), defineOptions()도 같은 매크로입니다.

매크로는 <script setup> 최상위에서만 인식됩니다.
setup() 바깥으로 끌어올려지므로, 지역 변수를 참조할 수 없습니다.
그리고 조건문이나 함수 안에서 호출하면 변환되지 않고 그대로 남아, 런타임 에러가 발생합니다.

# Emits

부모 컴포넌트에서 Props로 전달한 데이터는 자식 컴포넌트에서 읽을 수 있지만, 변경할 수는 없습니다.
만약 변경이 필요하면, 자식 컴포넌트에서 부모 컴포넌트로 Emits을 사용해 (커스텀) 이벤트 발생과 함께 데이터를 전달할 수 있습니다.
혹은 데이터를 포함하지 않고 이벤트만 발생시킬 수도 있습니다.

defineEmits()도 컴파일러 매크로이므로, 별도 가져오기 없이 바로 사용할 수 있습니다.
발생시킬 이벤트 이름을 정의하고, 첨부할 데이터는 이벤트를 발생시키는 시점에 전달합니다.

defineEmits()에서 반환된 emit 함수를 사용하거나, 템플릿에서 $emit 함수를 사용합니다.

TS
1
2
3
const emit = defineEmits(['커스텀이벤트', ...]) emit('커스텀이벤트', 데이터)
/src/components/TextField.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> const { myValue, backgroundColor = 'yellow' } = defineProps<{ myValue: string backgroundColor?: string }>() const emit = defineEmits(['update', 'search']) console.log(myValue, backgroundColor, emit) function handler(event: Event) { emit('update', (event.target as HTMLInputElement)?.value) } </script> <template> <input :style="{ backgroundColor }" :value="myValue" @input="handler" /> <button @click="$emit('search', myValue)">검색</button> </template>

Emits로 발생시킨 이벤트는 부모 컴포넌트에서 v-on(@) 디렉티브를 사용해 감지할 수 있습니다.
그리고 첨부한 데이터는 핸들러의 첫 번째 인수(템플릿에서는 $event)로 전달됩니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts"> import { ref } from 'vue' import TextField from './components/TextField.vue' const text = ref('') function search(keyword: string) { console.log(keyword) // 입력한 값 } </script> <template> <TextField :my-value="text" @update="text = $event" @search="search" /> </template>

# Slots

HTML 요소처럼 열리고 닫히는 컴포넌트 태그 사이에 작성한 모든 내용(Contents)은 자식 컴포넌트의 슬롯(Slot)으로 전달됩니다.

VUE
1
2
3
4
5
6
7
<template> <MyComponent> <h2></h2> <div></div> <p></p> </MyComponent> </template>

# 기본 슬롯과 대체 콘텐츠

name 속성이 없는(이름이 없는) <slot> 요소를 기본 슬롯(Default Slot) 혹은 슬롯 아울렛(Slot Outlet)이라고 합니다.
그리고 컴포넌트 슬롯으로 전달되는 내용이 없을 때, 출력될 대체 내용(Fallback Content)은 열리고 닫히는 <slot> 태그 사이에 작성할 수 있습니다.

/src/components/TheButton.vue
VUE
1
2
3
4
5
<template> <button class="btn"> <slot>버튼 이름이 없습니다..</slot> </button> </template>
/src/App.vue
VUE
1
2
3
4
5
6
7
8
<script setup lang="ts"> import TheButton from './components/TheButton.vue' </script> <template> <TheButton /> <TheButton>클릭하세요!</TheButton> </template>
HTML
1
2
<button class="btn">버튼 이름이 없습니다..</button> <button class="btn">클릭하세요!</button>
출력된 결과.

# 이름 슬롯

<slot> 요소에 name 속성을 추가해 이름을 지정할 수 있으며, 이를 이름이 있는 슬롯(Named Slots)이라고 합니다.
name 속성이 없는 <slot> 요소의 이름은 default 이고 생략 가능합니다.

각 슬롯에 추가할 내용에 #슬롯이름으로 이름을 일치시킬 수 있습니다.

/src/components/TheButton.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<template> <button class="btn"> <slot name="abc"></slot> <slot></slot> <slot name="xyz"></slot> </button> </template> <style scoped> span { display: block; } </style>
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts"> import TheButton from './components/TheButton.vue' </script> <template> <TheButton> <template #abc>ABC</template> <template #default>DEFAULT</template> <template #xyz>XYZ</template> </TheButton> </template>

혹은, 기본 슬롯은 다음과 같이 이름을 생략할 수 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
<template> <TheButton> <template #abc>ABC</template> DEFAULT <template #xyz>XYZ</template> </TheButton> </template>
HTML
1
2
3
4
5
<button class="btn"> ABC DEFAULT XYZ </button>
출력된 결과.

# 범위 슬롯

범위가 지정된 슬롯(Scoped Slots)은 <slot> 요소를 통해 데이터를 전달할 수 있는 패턴을 제공합니다.
자식 컴포넌트의 슬롯으로 부모 컴포넌트로 (반응형) 데이터를 전달할 수 있습니다.
단, 그 데이터의 유효 범위(Scoped)는 슬롯 내용(Slot Content)으로 제한됩니다.

/src/components/TheMenu.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<script setup lang="ts"> import { ref } from 'vue' const isShow = ref(false) function showMenu() { isShow.value = true window.addEventListener('click', hideMenu) } function hideMenu() { isShow.value = false window.removeEventListener('click', hideMenu) } function toggleMenu(event: MouseEvent) { event.stopPropagation() isShow.value ? hideMenu() : showMenu() } </script> <template> <slot name="activator" :status="isShow" :attrs="{ onClick: toggleMenu }"></slot> <div v-if="isShow" class="the-menu" @click.stop> <slot></slot> </div> </template>
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts"> import TheMenu from './components/TheMenu.vue' </script> <template> <TheMenu> <template #activator="{ attrs, status }"> <button v-bind="attrs"> 메뉴! ({{ status }}) </button> </template> <!-- attrs, status 변수는 이곳에서 사용할 수 없습니다! --> <ul> <li>사과</li> <li>바나나</li> <li>체리</li> </ul> </TheMenu> </template>
HTML
1
2
<button>메뉴! (false)</button> <!-- v-if -->
출력된 결과.

# 폴스루 속성

Props에 정의한 속성은 직접 요소에 연결하지 않으면, 출력되지 않습니다.
반대로 Props에 정의하지 않았지만, 컴포넌트를 사용할 때 작성하는 속성을 폴스루 속성(Fallthrough Attributes)이라고 합니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
<script setup lang="ts"> import MyComponent from './components/MyComponent.vue' </script> <template> <MyComponent c="C" d="D" /> </template>
부모 컴포넌트에서 자식 컴포넌트로 c와 d 속성을 통해 문자 전달

모든 폴스루 속성은 자동으로 컴포넌트 최상위 요소에 연결(상속, Inheritance)됩니다.

/src/components/MyComponent.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> // 사용하지 않은, Props로 정의한 속성 a와 b defineProps<{ a?: string b?: number }>() </script> <template> <div> <span>Hello!</span> </div> </template>
단일 최상위 요소만 가지는 컴포넌트
HTML
1
2
3
<div c="C" d="D"> <span>Hello!</span> </div>
출력된 결과, 단일 최상위 요소에 자동으로 연결된 c와 d 속성.

반대로 최상위 요소가 2개 이상 경우, 폴스루 속성은 어떤 요소에도 연결되지 않습니다.

/src/components/MyComponent.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts"> defineProps<{ a?: string b?: number }>() </script> <template> <div> <span>Hello!</span> </div> <h2>Hi?</h2> <p>Greetings~</p> </template>
다중 최상위 요소를 가지는 컴포넌트
HTML
1
2
3
4
5
<div> <span>Hello!</span> </div> <h2>Hi?</h2> <p>Greetings~</p>
출력된 결과, 어떤 요소에도 연결되지 않은 c와 d 속성.

최상위 요소가 2개 이상일 때, 폴스루 속성을 원하는 요소로 전달하려면 $attrs 내장 객체를 v-bind 속성으로 연결합니다.

VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts"> defineProps<{ a?: string b?: number }>() </script> <template> <div> <span v-bind="$attrs">Hello!</span> </div> <h2>Hi?</h2> <p>Greetings~</p> </template>
HTML
1
2
3
4
5
<div> <span c="C" d="D">Hello!</span> </div> <h2>Hi?</h2> <p>Greetings~</p>
출력된 결과, 원하는 요소에 연결된 c와 d 속성.

필요한 경우, 최상위 요소가 하나라도 폴스루 속성이 자동으로 연결되지 않도록 처리할 수 있습니다.

/src/components/MyComponent.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> defineProps<{ a?: string b?: number }>() defineOptions({ inheritAttrs: false // 폴스루 속성의 상속 옵션 OFF }) </script> <template> <div> <span>Hello!</span> </div> </template>

폴스루 속성은 스크립트에서도 접근할 수 있습니다.
그리고 attrs($attrs) 객체는 onClick과 같이 이벤트를 포함합니다.(Props는 포함하지 않습니다)

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts"> import MyComponent from './components/MyComponent.vue' function handler() { console.log('Clicked!!') } </script> <template> <MyComponent a="A" :b="2" c="C" d="D" @click="handler" /> </template>
/src/components/MyComponent.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts"> import { useAttrs } from 'vue' defineProps<{ a?: string b?: number }>() const attrs = useAttrs() console.log(attrs) // 출력! </script> <template> <div> <span>Hello!</span> </div> </template>
JS
1
2
3
4
5
{ c: "C", d: "D", onClick: f handler() }
출력된 결과.

# 양방향 데이터 바인딩(v-model)

부모 컴포넌트에서 정의된 반응형 데이터를 Props로 전달하면, 자식 컴포넌트에서는 읽기 전용으로만 사용할 수 있습니다.
만약, 반응형 데이터를 수정하려면, 쓰기 권한을 가진 부모 컴포넌트로 Emits을 사용해 (커스텀) 이벤트 발생과 데이터를 전달하는 패턴이 필요합니다.
이 패턴에서 사용하는 Props와 Emits 이름은 무엇이든 가능합니다.

/src/components/TextField.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts"> defineProps<{ abc: string label?: string }>() defineEmits(['xyz']) </script> <template> <label> {{ label }} <input v-bind="$attrs" :value="abc" @input="$emit('xyz', ($event.target as HTMLInputElement)?.value)" /> </label> </template> <style scoped> label { display: block; } </style>
'abc' Prop의 수정을 위해 'xyz' 커스텀 이벤트 발생
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script setup lang="ts"> import { ref } from 'vue' import TextField from './components/TextField.vue' const email = ref('') const password = ref('') function signIn() { console.log('로그인 정보:', email.value, password.value) } </script> <template> <form @submit.prevent="signIn"> <TextField :abc="email" label="이메일" autocomplete="username" @xyz="email = $event" /> <TextField :abc="password" label="비밀번호" type="password" autocomplete="current-password" @xyz="password = $event" /> <button type="submit">로그인</button> </form> </template>

만약 Props와 Emits 이름을 (Vue에서 약속된) modelValueupdate:modelValue로 지정하면, 축약형으로 v-model 디렉티브를 사용할 수 있습니다.

/src/components/TextField.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup lang="ts"> defineProps<{ modelValue: string label?: string }>() defineEmits(['update:modelValue']) </script> <template> <label> {{ label }} <input v-bind="$attrs" :value="modelValue" @input="$emit('update:modelValue', ($event.target as HTMLInputElement)?.value)" /> </label> </template> <style scoped> label { display: block; } </style>
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts"> // 생략.. </script> <template> <form @submit.prevent="signIn"> <TextField v-model="email" label="이메일" autocomplete="username" /> <TextField v-model="password" label="비밀번호" type="password" autocomplete="current-password" /> <button type="submit">로그인</button> </form> </template>

다만 자식 컴포넌트에서 Prop과 Emits을 매번 한 쌍으로 선언하는 것은 번거롭습니다.
defineModel() 매크로를 사용하면, 두 선언을 하나로 대체할 수 있습니다.
defineModel()이 반환한 참조(Ref) 객체를 그대로 사용하면, 값을 읽고 쓰는 것만으로 부모 컴포넌트의 데이터가 갱신됩니다.

defineModel() 매크로는 3.4+에서 사용할 수 있으며, 컴포넌트 v-model 구현의 권장 방식입니다.

/src/components/TextField.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> const model = defineModel<string>() defineProps<{ label?: string }>() </script> <template> <label> {{ label }} <input v-bind="$attrs" v-model="model" /> </label> </template> <style scoped> label { display: block; } </style>

부모 컴포넌트에서 사용하는 방법은 앞의 v-model 예제와 같습니다.

하나의 컴포넌트에서 여러 개의 양방향 데이터가 필요하면, defineModel()에 이름을 지정하고 v-model:이름 패턴으로 연결합니다.

/src/components/UserName.vue
VUE
1
2
3
4
5
6
7
8
9
<script setup lang="ts"> const firstName = defineModel<string>('firstName') const lastName = defineModel<string>('lastName') </script> <template> <input v-model="firstName" /> <input v-model="lastName" /> </template>
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> import { ref } from 'vue' import UserName from './components/UserName.vue' const first = ref('') const last = ref('') </script> <template> <UserName v-model:first-name="first" v-model:last-name="last" /> </template>

# 동적 컴포넌트

<component :is="컴포넌트" /> 방식의 특수 요소(Special Element)를 활용해 동적으로 컴포넌트를 출력할 수 있습니다.

<component>, <slot>, <template>는 Vue에서 사용하는 특수한 기능의 내장 요소들입니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<script setup lang="ts"> import { ref } from 'vue' import type { Component } from 'vue' import Home from './components/Home.vue' import About from './components/About.vue' import Posts from './components/Posts.vue' interface Tabs { [key: string]: Component // 인덱스 시그니처! } const currentTab = ref('Home') const tabs: Tabs = { Home, About, Posts } function changeTab(tab: string | number) { if (typeof tab === 'string') { currentTab.value = tab } } </script> <template> <button v-for="(_, tab) in tabs" :key="tab" @click="changeTab(tab)"> {{ tab }} </button> <component :is="tabs[currentTab]" /> </template>
버튼을 선택할 때마다 동적으로 변경(출력)되는 컴포넌트

# Provide / Inject

앞서 살펴본 것처럼, 부모에서 자식 컴포넌트로 데이터를 전달할 때는 Props 방식을 사용합니다.
그런데 만약 부모와 자식 관계 이상으로 상위에서 하위 컴포넌트로 데이터를 한 번에 전달하려면, 부모-자식 단계를 거쳐 데이터를 전달해야 하는 Props 대신, Provide / Inject 방식을 사용할 수 있습니다.

TS
1
2
3
4
5
// 상위 컴포넌트 provide(이름, 데이터) // 하위 컴포넌트 const 데이터 = inject(이름)

다음은 자식 컴포넌트를 거치지 않고, 바로 하위 컴포넌트로 데이터를 전달하는 예제입니다.
주의할 점은, 반응성을 유지하기 위해 count.value와 같이 단순히 값을 전달하는 것이 아닌 count와 같이 반응형 데이터를 전달해야 합니다.

ref(), reactive(), computed() 등의 모든 반응형 데이터는 반응성을 유지할 수 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts"> import { ref, provide } from 'vue' import Parent from './components/Parent.vue' const count = ref(0) provide('count', count) </script> <template> <button @click="count += 1"> 증가! </button> <Parent /> </template>
/src/components/Parent.vue
VUE
1
2
3
4
5
6
7
<script setup lang="ts"> import Child from './Child.vue' </script> <template> <Child /> </template>
/src/components/Child.vue
VUE
1
2
3
4
5
6
7
8
9
<script setup lang="ts"> import { inject } from 'vue' const count = inject('count') </script> <template> <h2>{{ count }}</h2> </template>

inject() 함수로 주입하는 데이터는 기본적으로 unknown 타입입니다.
물론 제네릭을 사용해 타입을 지정할 수 있지만, 런타임에 이 값이 제공된다는 보장이 없으므로 주입된 값은 여전히 undefined가 될 수 있습니다.

/src/components/Child.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> import { inject } from 'vue' import type { Ref } from 'vue' const count = inject<Ref<number>>('count') // const count: Ref<number> | undefined console.log(count.value) // Error! - 'count'은(는) 'undefined'일 수 있습니다.ts(18048) </script> <template> <h2>{{ count }}</h2> </template>

따라서 타입 가드를 추가하거나,
만약 값이 항상 제공된다고 확신한다면, 타입을 단언(Assertion)할 수도 있습니다.

/src/components/Child.vue
TS
1
2
3
4
5
const count = inject<Ref<number>>('count') if (count && typeof count.value === 'number') { console.log(count.value) }
타입 가드를 추가한 경우.
/src/components/Child.vue
TS
1
2
3
const count = inject('count') as Ref<number> console.log(count.value)
타입을 단언한 경우.

provide() 함수로 제공할 데이터를 객체 형태로 전달하면, 여러 데이터를 한 번에 전달할 수 있습니다.
다음과 같이 반응형 데이터를 변경하는 함수를 같이 제공해, 하위 요소에서 사용할 수도 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script setup lang="ts"> import { ref, provide } from 'vue' import Parent from './components/Parent.vue' const count = ref(0) provide('App.vue', { count, increase }) // 제공 데이터의 타이핑! export interface Provide { count: typeof count increase: typeof increase } function increase() { count.value += 1 } </script> <template> <button @click="increase">증가!(App)</button> <Parent /> </template>
/src/components/Child.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts"> import { inject } from 'vue' import type { Provide } from '../App.vue' const { count, increase } = inject('App.vue') as Provide </script> <template> <button @click="increase">증가!(Child)</button> <h2>{{ count }}</h2> </template>
Child 컴포넌트의 '증가!'' 버튼은 잘 동작합니다.