[Vue 빠른시작] 컴포넌트 기초 예제 추가(Composition API ver)

이 파트도 공부하다보니 api 버전을 나눠야할 필요가 있을것 같아 따로 작성한다.

이전글 보기 :
[Vue 빠른시작] 컴포넌트 기초 예제 추가(Options API ver)



컴포넌트를 사용하면 UI를 독립적이고 재사용 가능한 일부분으로 분할하고 각 부분을 개별적으로 다룰 수 있다. 따라서 앱이 중첩된 컴포넌트의 트리로 구성되는 것은 일반적이다.

이것은 기본 HTML 엘리먼트를 중첩하는 방법과 매우 유사하지만, Vue는 각 컴포넌트에 사용자 정의 컨텐츠와 논리를 캡슐화할 수 있는 자체 컴포넌트 모델을 구현한다. Vue는 기본 웹 컴포넌트와도 잘 작동한다.



1. Defining a Component

빌드 방식을 사용할 때 일반적으로 Single-File Component(SFC)라고 하는 .vue 확장자를 사용하는 전용 파일에 각 Vue 컴포넌트를 정의한다.

// Composition API ver
<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
    <button @click="count++">당신은 {{ count }}번 클릭했습니다</button>
</template>

빌드 방식을 사용하지 않을 때, Vue 컴포넌트는 Vue 관련 옵션을 포함하는 일반 JavaScript 객체로 정의할 수 있다.

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    return { count }
  },
  template: `
    <button @click="count++">
      당신은 {{ count }} 번 클릭했습니다.
    </button>`
  // 또는 `template: '#my-template-element'`
}

JavaScript 문자열로 정의한 템플릿은 Vue가 즉석에서 컴파일한다. 엘리먼트(보통 기본 <'template> 엘리먼트)를 가리키는 ID 셀렉터를 사용할 수도 있다. Vue는 해당 컨텐츠를 템플릿 소스로 사용한다.

위의 예는 단일 컴포넌트를 정의하고 이를 .js 파일의 내보내기 기본 값으로 내보낸다. 그러나 명명된 내보내기를 사용하여 한 파일에서 여러 개의 컴포넌트로 내보낼 수 있다.



2. Using a Component

자식 컴포넌트를 사용하려면 부모 컴포넌트에서 가져와야 한다. 파일 안에 ButtonCounter.vue라는 카운터 컴포넌트를 배치했다고 가정하면, 해당 컴포넌트 파일의 기본 내보내기가 노출된다.

// Composition API ver
<script setup>
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
  <h1>아래에 자식 컴포넌트가 있습니다.</h1>
  <ButtonCounter />
</template>

<script setup>을 사용하면 가져온 컴포넌트를 템플릿에서 자동으로 사용할 수 있다.

컴포넌트를 전역으로 등록하면, 가져오기(import) 없이 지정된 앱의 모든 곳에서 컴포넌트를 사용할 수 있다.

또 컴포넌트는 원하는 만큼 재사용할 수 있다.

<h1>여기에 많은 자식 컴포넌트가 있습니다!</h1>
<ButtonCounter />
<ButtonCounter />
<ButtonCounter />

실행화면

버튼을 클릭할 때 각 버튼은 독립적인 count를 유지한다. 컴포넌트를 사용할 때마다 해당 컴포넌트의 새 인스턴스가 생성되기 때문이다.

SFC에서는 네이티브 HTML 엘리먼트와 구별하기 위해 자식 컴포넌트에 PascalCase 태그 이름을 사용하는 것이 좋다. 기본 HTML 태그 이름은 대소문자를 구분하지 않지만, Vue의 SFC는 컴파일된 포멧으로 대소문자를 구분하여 태그 이름을 사용할 수 있다. 또한 /> 를 사용하여 태그를 닫을 수 있다.

템플릿을 DOM에서 직접 작성하는 경우(예: 기본 <template> 엘리먼트의 컨텐츠로), 템플릿은 브라우저의 기본 HTML 구문 분석 동작을 따른다. 이러한 경우 컴포넌트는 kebab-case 및 명시적 닫는 태그를 사용해야 한다.

<!-- 이 템플릿이 DOM에 작성된 경우 -->
<button-counter></button-counter>
<button-counter></button-counter>
<button-counter></button-counter>



3. Passing Props

props은 컴포넌트에 등록할 수 있는 사용자 정의 속성이다. 컴포넌트에 전달하려면 defineProps메크로를 사용해야 한다.

// Composition API ver
<!-- BlogPost.vue -->
<script setup>
defineProps(['title'])
</script>

<template>
  <h4>{{ title }}</h4>
</template>

defineProps<script setup>내에서만 사용할 수 있는 컴파일 타임 매크로이며, 템플릿에 선언된 props는 자동으로 노출된다.(컴파일러 매크로이기 때문에 개발 환경 설정에 따라 lint 에러나 경고가 나올수 있다) 그리고 defineProps는 컴포넌트에 전달된 모든 props를 객체로 반환하므로, 필요한 경우 JavaScript에서 접근할 수 있다.

import { defineProps } from 'vue'
const props = defineProps(['title'])
console.log(props.title)

<script setup>을 사용하지 않는 경우, props옵션을 선언해서 사용해야 하며, props객체는 setup()에 첫 번째 인자로 전달된다.

export default {
  props: ['title'],
  setup(props) {
    console.log(props.title)
  }
}

컴포넌트는 원하는 만큼 props를 가질 수 있으며, 기본적으로 모든 값을 모두 props에 전달할 수 있다.

props가 등록되면, 다음과 같이 데이터를 사용자 정의 속성으로 전달할 수 있다.

<BlogPost title="Vue와 함께한 나의 여행" />
<BlogPost title="Vue로 블로깅하기" />
<BlogPost title="Vue가 재미있는 이유" />

그러나 일반적인 앱에서는 부모 컴포넌트에 다음과 같은 게시물 배열이 있을 수 있다.

const posts = ref([
  { id: 1, title: 'Vue와 함께한 나의 여행' },
  { id: 2, title: 'Vue로 블로깅하기' },
  { id: 3, title: 'Vue가 재미있는 이유' }
])

그런 다음 v-for를 사용하여 각각을 컴포넌트로 렌더링하려고 한다.

<BlogPost
  v-for="post in posts"
  :key="post.id"
  :title="post.title"
 />

v-bind가 동적 prop 값을 전달하는데 사용되었다. 이것은 미리 렌더링할 정확한 콘텐츠를 모를 때 특히 유용하다.



4. Listening to Events

<BlogPost> 컴포넌트를 개발할 때 일부 기능은 상위 항목과 다시 통신해야 할 수 있다. 부모 컴포넌트에서 pontFontSize ref를 추가하여 이 기능을 지원할 수 있다.

const posts = ref([
  /* ... */
])

const postFontSize = ref(1)

템플릿에서 모든 블로그 게시물의 글꼴 크기를 제어하는 데 사용할 수 있다.

<div :style="{ fontSize: postFontSize + 'em' }">
  <BlogPost
    v-for="post in posts"
    :key="post.id"
    :title="post.title"
   />
</div>

이제 <BlogPost> 컴포넌트의 템플릿에 버튼을 추가해 보자

<!-- BlogPost.vue의 <script> 생략 -->
<template>
  <div class="blog-post">
    <h4>{{ title }}</h4>
    <button>텍스트 확대</button>
  </div>
</template>

컴포넌트는 커스텀 이벤트 시스템을 제공한다. 부모 컴포넌트는 네이티브 DOM 이벤트와 마찬가지로 v-on 또는 @를 사용하여 자식 컴포넌트 인스턴스의 모든 이벤트를 수신하도록 선택할 수 있다.

<BlogPost
  ...
  @enlarge-text="postFontSize += 0.1"
 />

그런 다음 자식 컴포넌트는 빌트인 $emit 메서드를 호출하고 이벤트 이름을 전달하여 자체적으로 이벤트를 생성할 수 있다.

<!-- BlogPost.vue의 <script> 생략 -->
<template>
  <div class="blog-post">
    <h4>{{ title }}</h4>
    <button @click="$emit('enlarge-text')">텍스트 확대</button>
  </div>
</template>

@enlarge-text="postFontSize += 0.1 리스너 덕분에 부모 컴포넌트는 이벤트를 수신하고 postFontSize 값을 업데이트한다.


$emit

현재 인스턴스에서 커스텀 이벤트를 트리거한다. 추가적인 인자는 리스너의 콜백 함수로 전달된다.

말이 어려운데 풀어서 설명하면 Vue에서 사용되는 이벤트 발송 메소드. 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하거나, 자식 컴포넌트에서 부모 컴포넌트로 이벤트를 전달할 때 주로 사용된다.

interface ComponentPublicInstance {
  $emit(event: string, ...args: any[]): void
}
export default {
  created() {
    // 커스텀 이벤트 'foo'만 트리거
    this.$emit('foo')
    // 추가 인자와 함께 트리거

this.$emit('bar', 1, 2, 3)
  }
}

defineEmits 매크로를 사용하여 원하는 이벤트를 선언할 수 있다.

<!-- BlogPost.vue -->
<script setup>
defineProps(['title'])
defineEmits(['enlarge-text'])
</script>

이것은 컴포넌트가 내보내는 모든 이벤트를 문서화하고 선택적으로 유효성 검사를 한다. 또한 Vue가 자식 컴포넌트의 루트 엘리먼트에 암시적으로 네이티브 리스너(브라우저의 이벤트)가 적용되는 것을 방지할 수 있다.

defineProps와 마찬가지로 defineEmits<script setup>에서만 사용할 수 있으며 import할 필요가 없다. $emit 메서드와 동일한 emit 함수를 반환하므로, 컴포넌트의 <script setup> 섹션에서 이벤트를 내보내는 데 사용할 수 있다.

<script setup>
const emit = defineEmits(['enlarge-text'])

emit('enlarge-text')
</script>

<script setup>을 사용하지 않는 경우, emits 옵션을 사용하여 내보낼 이벤트를 선언할 수 있다. setup 컨텍스트의 속성으로 emits 함수에 접근할 수 있다.(setup()의 두 번째 인자로 전달됨)

export default {
  emits: ['enlarge-text'],
  setup(props, ctx) {
    ctx.emit('enlarge-text')
  }
}

emits

본문 말이 어려워서 이해가 안가서 풀어서 다시 설명함!😒

emits 옵션은 Vue 3에서 도입된 기능으로, 부모 컴포넌트에서 자식 컴포넌트로 이벤트를 전송할 때 사용되는 이벤트 이름을 명시적으로 선언한다. 이를 통해 컴포넌트 간의 커뮤니케이션을 더 명시적이고 견고하게 만들 수 있다.

여기서 명시적으로 선언한다는 것은 props나 emits나 배열로 선언되어서 프론트가 생소한 나같은 사람은 'porps[index] 이런 형식으로 참조해야하는거 아닌가?' 하는 생각을 했는데 Vue 문법상 그것이 아니라 '명시적인 이름' 즉 명명된 이름 자체로 사용을 한다고 한다. 명시적인 이름을 사용해 가독성을 높여 코드를 더 이해하기 쉽게 만드는 것이라고 한다.




5. Content Distribution with Slots

Vue의 사용자 정의 <slot> 엘리먼트를 사용하여 컴포넌트에 컨텐츠를 전달할 수 있다. 컨텐츠를 이동하려는 자리 표시자로 <slot>을 사용한다.

// AlertBox.vue
<template>
  <div class="alert-box">
    <strong>이것은 데모용 오류입니다.</strong>
    <br/>
    <slot />
  </div>
</template>

<style scoped>
.alert-box {
  color: #666;
  border: 1px solid red;
  border-radius: 4px;
  padding: 20px;
  background-color: #f8f8f8;
}

strong {
    color: red;    
}
</style>
// Composition API ver
// App.vue
<script setup>
import AlertBox from './AlertBox.vue'
</script>

<template>
    <AlertBox>
      나쁜 일이 일어났습니다.
    </AlertBox>
</template>



6. Dynamic Components

탭 인터페이스와 같이 컴포넌트 간에 동적으로 전환하려고 할 때 <component> 엘리먼트의 is 속성을 사용할 수 있다.

// 탭 역할을 할 컴포넌트는 아래와 같은 양식을 가진다.
<template>
  <div class="tab">
    Home component
  </div>
</template>
// Composition API ver
<script setup>
import Home from './Home.vue'
import Posts from './Posts.vue'
import Archive from './Archive.vue'
import { ref } from 'vue'

const currentTab = ref('Home')

const tabs = {
  Home,
  Posts,
  Archive
}
</script>

<template>
  <div class="demo">
    <button
       v-for="(_, tab) in tabs"
       :key="tab"
       :class="['tab-button', { active: currentTab === tab }]"
       @click="currentTab = tab"
     >
      {{ tab }}
    </button>
      <component :is="tabs[currentTab]" class="tab"></component>
  </div>
</template>

<style>
.demo {
  font-family: sans-serif;
  border: 1px solid #eee;
  border-radius: 2px;
  padding: 20px 30px;
  margin-top: 1em;
  margin-bottom: 40px;
  user-select: none;
  overflow-x: auto;
}

.tab-button {
  padding: 6px 10px;
  border-top-left-radius: 3px;
  border-top-right-radius: 3px;
  border: 1px solid #ccc;
  cursor: pointer;
  background: #f0f0f0;
  margin-bottom: -1px;
  margin-right: -1px;
}
.tab-button:hover {
  background: #e0e0e0;
}
.tab-button.active {
  background: #e0e0e0;
}
.tab {
  border: 1px solid #ccc;
  padding: 10px;
}
</style>

실행화면

:is에 전달되는 값은 다음 중 하나를 포함할 수 있다

  • 등록된 컴포넌트의 이름 문자열
  • 실제 가져온 컴포넌트 객체

is 속성을 사용하여 일반 HTML 엘리먼트를 만들 수도 있다.

<component :is="...">를 사용하여 여러 컴포넌트 간에 전환할 때, 다른 컴포넌트로 전환되면 컴포넌트가 마운트 해제된다. 내장된 <KeepAlive> 컴포넌트를 사용하여 비활성 컴포넌트를 "활성" 상태로 유지하도록 강제할 수 있다.


6-1. KeepAlive

기본적으로 활성 컴포넌트 인스턴스느 전환할 때 마운트 해제된다. 이렇게 하면 해당 컴포넌트가 보유한 모든 변경된 상태가 손실된다. 이 컴포넌트가 다시 표시되면 초기 상태로만 새 인스턴스가 생성된다.

예로 탭으로 나누어진 두 컴포넌트에서 상태를 업데이트하고 다른 곳으로 전환했다가 다시 전환해 보면 이전에 변경한 상태가 초기화되었음을 알 수 있다.

스위치에서 새 컴포넌트 인스턴스를 만드는 것은 일반적으로 유용한 동작이지만 이 경우에는 두 컴포넌트 인스턴스가 비활성 상태인 경우에도 상태가 보존되길 원한다. 이 문제를 해결하기 위해 동적 컴포넌트를 빌트인 컴포넌트인 <KeepAlive>로 래핑할 수 있다.

<!-- 비활성 컴포넌트가 캐시됩니다! -->
<KeepAlive>
  <component :is="activeComponent" />
</KeepAlive>

TIP!

DOM 템플릿에서 사용할 때 <keep-alive>로 참조해야 한다.

아래는 실행 해보기 위한 예제코드
메인과 CompA.vue, CompB.vue로 구성되어 있다.

// 메인
<script setup>
import { shallowRef } from "vue";
import CompA from './CompA.vue'
import CompB from './CompB.vue'

const current = shallowRef(CompA)
</script>

<template>
    <div class="demo">
        <label><input type="radio" v-model="current" :value="CompA"> A</label>
        <label><input type="radio" v-model="current" :value="CompB"> B</label>
        <!-- <KeepAlive> -->
            <component :is="current"></component>
        <!-- </KeepAlive> -->
    </div>
</template>
// CompA.vue
<script setup>
import { ref } from "vue";

const count = ref(0)
</script>

<template>
    <p>Current component: A</p>
    <span>count: {{ count }}</span>
    <button @click="count++">+</button>
</template>
// CompB.vue
<script setup>
import { ref } from "vue";

const msg = ref('')
</script>

<template>
    <p>Current component: B</p>
    <span>Message is: {{ msg }}</span>
    <input v-model="msg">
</template>

위 예제에서 모르는게 또 나와

6-2. shallowRef

ref()의 얕은 버전. shallowRef()의 내부 값은 있는 그대로 저장되고 노출되며 내부 깊숙이까지 반응형으로 동작하지는 않는다. .value접근만 반응형이다.

shallowRef()는 일반적으로 대규모 데이터 구조의 성능 최적화 또는 외부 상태 관리 시스템과의 통합에 사용된다.

<script setup>
import { shallowRef, watchEffect } from "vue";

const user = shallowRef({
    name: 'John',
    age: 25
});

watchEffect(() => {
    console.log('User changed: ', user.value);
});

// change(변경)을 트리거하지 않음
user.value.age = 55;

// change를 트리거 함
user.value = {
    name: 'Bob',
    age: 28
}
</script>



7. in-DOM Template Parsing Caveats(주의사항)

Vue 템플릿을 DOM에서 직접 작성하는 경우, Vue는 DOM에서 템플릿 문자열을 검색해야 한다. 이것은 브라우저의 기본 HTML 파싱 동작으로 인해 몇 가지 주의 사항으로 이어진다.

TIP!

아래에 설명된 제한 사항은 템플릿을 DOM에서 직접 작성하는 경우에만 적용된다는 점에 유의해야 한다. 다음 소스의 문자열 템플릿을 사용하는 경우에는 적용되지 않는다.


✔️ 싱글 파일 컴포넌트

✔️ 인라인 템플릿 문자열(ex. template: '...')

✔️ <'script type="text/x-template">

7-1. 대소문자를 구분하지 않음

HTML 태그와 속성의 이름은 대소문자를 구분하지 않으므로 브라우저는 대문자를 소문자로 해석한다. 즉, DOM 내 템플릿을 사용할 때 PascalCase 컴포넌트 이름과 props의 camelCased 이름 또는 v-on 이벤트 이름은 모두 kebab-case 기반으로 사용해야 한다.

// JavaScript에서 camelCase
const BlogPost = {
  props: ['postTitle'],
  emits: ['updatePost'],
  template: `
    <h3>{{ postTitle }}</h3>
  `
}
<!-- HTML에서 kebab-case -->
<blog-post post-title="안녕!" @update-post="onUpdatePost"></blog-post>

7-2. 셀프 태그 닫기

DOM 템플릿에서는 항상 명시적인 닫는 태그를 포함해야 한다.

<my-component></my-component>

이는 HTML 사양에서 몇 가지 특정 엘리먼트가 닫는 태그를 생략할 수 있도록 허용하기 때문이다. 가장 일반적인 것은 input, img 등이 있다. 다른 모든 엘리먼트의 경우, 닫는 태그를 생략하면 기본 HTML 파서는 사용자가 여는 태그를 종료하지 않은 것으로 간주한다.

<my-component /> <!-- 우리는 여기서 태그를 닫으려 했습니다... -->
<span>hello</span>

위 코드는 아래와 같이 파싱된다.

<my-component>
  <span>안녕</span>
</my-component> <!-- 그러나 브라우저는 여기에서 닫을 것입니다. -->

7-3. 엘리먼트 배치 제한

ul, ol, table, select와 같은 일부 HTNL 엘리먼트에는 내부에 표시할 수 있는 엘리먼트에 대한 제한이 있다. 또한 li, tr 및 option과 같은 일부 엘리먼트는 특정 다른 엘리먼트 내부에만 사용할 수 있다.

이러한 제한이 있는 엘리먼트가 있는 컴포넌트를 사용할 때 문제가 발생한다.

<table>
  <blog-post-row></blog-post-row>
</table>

위 코드에서 사용자 정의 컴포넌트 blog-post-row는 잘못된 컨텐츠로 hoisted되어 최종적으로 렌더링된 출력에서 에러가 발생한다. 특별한 is 속성을 해결 방법으로 사용할 수 있다.

<table>
  <tr is="vue:blog-post-row"></tr>
</table>

TIP!

기본 HTML 엘리먼트에 사용되는 경우, is 값은 Vue 컴포넌트로 해석되기 위해 vue: 접두사를 사용해야 한다. 이는 기본 맞춤형 내장 엘리먼트와의 혼동을 피하기 위해 필요하다.