- 예시
- Vue 애플리케이션을 추가해야 하는 경우
- 페이지에 여러 Vue 애플리케이션 추가를 피하십시오
- Vue 아키텍처
- 스타일 가이드
- Composition API
- Vue 컴포넌트 테스트
- Vue.js 전문가 역할
- Vue 2 -> Vue 3 마이그레이션
- 부록 - 테스트 대상 Vue 컴포넌트
Vue
Vue를 시작하려면 문서를 읽어보세요.
예시
다음 섹션에서 설명하는 내용은 다음 예시에서 찾을 수 있습니다:
Vue 애플리케이션을 추가해야 하는 경우
가끔, HAML 페이지는 요구 사항을 충족하기에 충분합니다. 이 진술은 주로 정적 페이지나 로직이 매우 적은 페이지에 대해 올바릅니다. 우리는 페이지에 Vue 애플리케이션을 추가해야 할 가치가 있는지 어떻게 알 수 있을까요? 답은 “애플리케이션 상태를 유지하고 렌더링된 페이지를 동기화해야 할 때”입니다.
이를 더 잘 설명하기 위해, 토글 하나가 있는 페이지를 상상해봅시다. 그리고 그 토글은 API 요청을 전송합니다. 이 경우, 유지하고 싶은 상태는 없으며 요청을 보내고 토글을 전환하는 것뿐입니다. 그러나, 첫 번째 토글과 항상 반대여야 하는 하나의 추가 토글을 추가한다면, 상태가 필요합니다. 하나의 토글은 다른 하나의 상태를 “인식”해야 합니다. 순수 자바스크립트로 작성할 때, 이러한 로직은 일반적으로 DOM 이벤트를 수신하고 DOM을 수정하여 반응하는 것을 포함합니다. 이러한 경우를 Vue.js로 처리하는 것이 훨씬 쉽기 때문에 여기에 Vue 애플리케이션을 만들어야 합니다.
Vue 애플리케이션이 필요하다는 신호는 무엇인가요?
- 여러 요소에 기반한 복잡한 조건부를 정의하고 사용자 상호 작용에 따라 업데이트해야 할 때
- 애플리케이션 상태를 유지하고 태그/요소 사이에 공유해야 할 때
- 미래에 복잡한 로직이 추가될 것으로 예상될 때 - 기본 Vue 애플리케이션을 시작하는 것이 더 쉽습니다. 다음 단계에서 JS/HAML을 Vue로 다시 작성해야 하는 것보다.
페이지에 여러 Vue 애플리케이션 추가를 피하십시오
과거에 우리는 페이지에 대한 상호 작용을 조각으로 추가하여 렌더링된 HAML 페이지의 여러 부분에 여러 작은 Vue 애플리케이션을 추가했습니다. 그러나, 이러한 접근 방식은 다음과 같은 여러 가지 복잡성으로 이어졌습니다:
- 대부분의 경우, 이러한 애플리케이션은 상태를 공유하지 않고 독립적으로 API 요청을 수행하여 요청의 수가 증가합니다.
- Rails로부터 Vue에 데이터를 제공해야 하는 경우 여러 엔드포인트를 통해 데이터를 제공해야 합니다.
- 페이지 구조가 유연하지 않아 Vue 애플리케이션을 동적으로 렌더링할 수 없으므로 페이지 구조가 엄격해집니다.
- Rails 라우팅을 대체하기 위해 클라이언트 측 라우팅을 완전히 활용할 수 없습니다.
- 여러 애플리케이션은 예측할 수 없는 사용자 경험, 페이지 복잡성의 증가, 더 어려운 디버깅 절차로 이어집니다.
- 애플리케이션 간의 통신 방식은 웹 성능 지표 숫자에 영향을 미칩니다.
이러한 이유로, 이미 존재하는 Vue 애플리케이션이 있는 페이지에 새로운 Vue 애플리케이션을 추가하는 데 조심해야 합니다 (이전 또는 새로운 내비게이션은 제외). 새 앱을 추가하기 전에 이미 있는 애플리케이션을 확장하여 원하는 기능을 달성하는 것이 절대 불가능한지 확인하십시오. 의심이 들면 #frontend
또는 #frontend-maintainers
슬랙 채널에서 구조상의 조언을 요청하십시오.
그래도 새로운 애플리케이션을 추가해야 하는 경우, 기존 애플리케이션과 지역 상태를 공유하는지 확인하십시오(REST API를 사용하는 경우 Apollo Client 또는 Vuex를 통해).
Vue 아키텍처
Vue 아키텍처를 통해 달성하려는 주요 목표는 데이터 흐름을 하나만 갖고 한 가지 데이터 입력만 갖는 것입니다. 이 목표를 달성하려면 Vuex 또는 Apollo Client를 사용합니다.
Vue 문서의 상태 관리 및 일방향 데이터 흐름에 대해 읽어볼 수도 있습니다.
컴포넌트 및 스토어
Vue.js로 구현된 기능 중에서 이슈 보드나 환경 테이블 같은 기능에서 관심사를 명확하게 분리할 수 있습니다:
new_feature
├── components
│ └── component.vue
│ └── ...
├── store
│ └── new_feature_store.js
├── index.js
일관성을 위해, 같은 구조를 따르는 것을 권장합니다.
각 항목에 대해 알아보겠습니다:
index.js
파일
이 파일은 새로운 기능의 인덱스 파일입니다. 새로운 기능의 루트 Vue 인스턴스가 여기에 있어야 합니다.
스토어 및 서비스는 이 파일에서 가져와 초기화되어 메인 컴포넌트에 속성으로 제공되어야 합니다.
페이지별 자바스크립트에 대해 읽어보세요.
부트스트래핑에 대한 주의사항
HAML에서 JavaScript로 데이터 제공
Vue 애플리케이션을 마운트하는 동안, 때로는 Rails로부터 JavaScript로 데이터를 제공해야 할 수 있습니다.
이를 위해, HTML 요소의 data
속성을 사용하고 애플리케이션을 마운트할 때 해당 데이터를 쿼리하면 됩니다.
Vue가 생성한 DOM으로 마운트된 요소만 초기화할 때만 이를 수행해야 합니다.
data
속성은 문자열 값만 허용하기 때문에 다른 변수 유형을 문자열로 변환해야 합니다.
메인 Vue 컴포넌트 내에서 DOM을 쿼리하는 대신 props
나 render
함수 내에서 DOM에서 props
로 데이터를 제공하는 것은 단위 테스트에서 픽처나 HTML 요소를 만드는 것을 피할 수 있습니다.
initSimpleApp
도우미
initSimpleApp
은 Vue.js에서 컴포넌트를 마운트하는 프로세스를 간소화하는 도우미 함수입니다. 셀렉터 문자열(HTML에서 마운트 지점을 나타냄)과 Vue 컴포넌트를 나타내는 두 가지 매개변수를 사용합니다.
initSimpleApp
사용 방법:
- 페이지에 ID나 고유한 클래스를 갖는 HTML 요소를 포함합니다.
- JSON 객체를 포함하는
data-view-model
속성을 추가합니다. - 원하는 Vue 컴포넌트를 가져와 유효한 CSS 선택기 문자열과 함께
initSimpleApp
에 전달합니다. 이 문자열은 특정 위치에서 컴포넌트를 마운트합니다.
initSimpleApp
은 data-view-model
속성의 내용을 JSON 객체로 자동으로 검색하고 이를 props로 마운트된 Vue 컴포넌트에 전달합니다. 이것은 컴포넌트를 데이터로 미리 채울 수 있는 데 사용할 수 있습니다.
예시:
//my_component.vue
<template>
<div>
<p>Prop1: {{ prop1 }}</p>
<p>Prop2: {{ prop2 }}</p>
</div>
</template>
<script>
export default {
name: 'MyComponent',
props: {
prop1: {
type: String,
required: true
},
prop2: {
type: Number,
required: true
}
}
}
</script>
<div id="js-my-element" data-view-model='{"prop1": "my object", "prop2": 42 }'></div>
//index.js
import MyComponent from './my_component.vue'
import { initSimpleApp } from '~/helpers/init_simple_app_helper'
initSimpleApp('#js-my-element', MyComponent)
provide
및 inject
Vue는 provide
및 inject
을 통해 의존성 주입을 지원합니다.
inject
구성에서 컴포넌트는 provide
로 전달된 값을 액세스합니다.
다음의 Vue 앱 초기화 예시는 provide
구성이 HAML에서 컴포넌트로 값을 전달하는 방법을 보여줍니다:
#js-vue-app{ data: { endpoint: 'foo' }}
// index.js
const el = document.getElementById('js-vue-app');
if (!el) return false;
const { endpoint } = el.dataset;
return new Vue({
el,
name: 'MyComponentRoot',
render(createElement) {
return createElement('my-component', {
provide: {
endpoint
},
});
},
});
컴포넌트나 해당 자식 컴포넌트는 inject
을 통해 속성에 액세스할 수 있습니다.
<script>
export default {
name: 'MyComponent',
inject: ['endpoint'],
...
...
};
</script>
<template>
...
...
</template>
의존성 주입을 사용하여 HAML에서 값을 제공하는 것은 이상적입니다:
- 주입된 값이 명시적으로 데이터 유형이나 내용에 대해 유효성을 검사할 필요가 없는 경우.
- 값이 반응적일 필요가 없는 경우.
- 같은 속성을 계층 구조에서 모든 컴포넌트를 통해 전달해야 하는 번거로움이 발생하는 경우.
의존성 주입은 잠재적으로 자식 컴포넌트(직접적인 자식 또는 여러 수준 이하)를 손상시킬 수 있습니다만 두 가지 조건이 충족될 때:
-
inject
구성에서 선언된 값에 기본값이 정의되지 않은 경우. - 부모 컴포넌트가
provide
구성을 사용하여 값이 제공되지 않은 경우.
기본값은 의미가 있는 상황에서 유용할 수 있습니다.
props
HAML로부터 가져온 값이 의존성 주입의 기준에 맞지 않는 경우 props
를 사용하세요.
다음 예시를 참조하세요.
// haml
#js-vue-app{ data: { endpoint: 'foo' }}
// index.js
const el = document.getElementById('js-vue-app');
if (!el) return false;
const { endpoint } = el.dataset;
return new Vue({
el,
name: 'MyComponentRoot',
render(createElement) {
return createElement('my-component', {
props: {
endpoint
},
});
},
});
id
속성을 추가할 경우 해당 id
가 코드베이스 전체에서 고유한지 확인하세요.Vue 앱으로 전달되는 데이터를 명시적으로 선언하는 이유에 대해 자세한 내용은 Vue 스타일 가이드를 참조하세요.
Rails 폼 필드를 Vue 애플리케이션에 제공
Rails로 양식(form)을 작성할 때 양식 입력의 name
, id
, value
속성은 백엔드에 맞게 생성됩니다. 이러한 생성된 속성에 액세스하는 것은 Rails 양식을 Vue로 변환하거나 컴포넌트를 통합할 때 유용할 수 있습니다.
parseRailsFormFields
유틸리티를 사용하여 생성된 양식 입력 속성을 구문 분석하여 Vue 애플리케이션에 전달할 수 있습니다.
이를 통해 양식의 전송 방식을 변경하지 않고 Vue 컴포넌트를 통합할 수 있습니다.
-# form.html.haml
= form_for user do |form|
.js-user-form
= form.text_field :name, class: 'form-control gl-form-input', data: { js_name: 'name' }
= form.text_field :email, class: 'form-control gl-form-input', data: { js_name: 'email' }
js_name
데이터 속성은 결과적으로 생성된 JavaScript 객체의 키로 사용됩니다.
예를 들어 = form.text_field :email, data: { js_name: 'fooBarBaz' }
는 { fooBarBaz: { name: 'user[email]', id: 'user_email', value: '' } }
로 번역됩니다.
// index.js
import Vue from 'vue';
import { parseRailsFormFields } from '~/lib/utils/forms';
import UserForm from './components/user_form.vue';
export const initUserForm = () => {
const el = document.querySelector('.js-user-form');
if (!el) {
return null;
}
const fields = parseRailsFormFields(el);
return new Vue({
el,
name: 'UserFormRoot',
render(h) {
return h(UserForm, {
props: {
fields,
},
});
},
});
};
<script>
// user_form.vue
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
export default {
name: 'UserForm',
components: { GlButton, GlFormGroup, GlFormInput },
props: {
fields: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div>
<gl-form-group :label-for="fields.name.id" :label="__('Name')">
<gl-form-input v-bind="fields.name" width="lg" />
</gl-form-group>
<gl-form-group :label-for="fields.email.id" :label="__('Email')">
<gl-form-input v-bind="fields.email" type="email" width="lg" />
</gl-form-group>
<gl-button type="submit" category="primary" variant="confirm">{{ __('Update') }}</gl-button>
</div>
</template>
gl
객체 접근
애플리케이션의 라이프사이클 동안 변경되지 않는 데이터를 DOM에서 쿼리하는 것과 동일한 위치에서 gl
객체를 쿼리합니다. 이러한 관행을 따르면 gl
객체를 모킹(mocking)하지 않고 테스트를 수월하게 할 수 있습니다. 이는 Vue 인스턴스를 초기화하는 동안 수행되어야 하며 데이터는 주요 컴포넌트로 props
로 제공해야 합니다.
return new Vue({
el: '.js-vue-app',
name: 'MyComponentRoot',
render(createElement) {
return createElement('my-component', {
props: {
avatarUrl: gl.avatarUrl,
},
});
},
});
능력에 접근하기
능력을 프론트엔드에 푸시한 이후에는 provide
및 inject
메커니즘을 사용하여 Vue 응용 프로그램의 하위 컴포넌트에서 능력을 사용할 수 있게 합니다. glAbilties
객체는 이미 commons/vue.js
에서 제공되므로 플래그를 사용하려면 믹스인만 필요합니다:
// 임의의 하위 컴포넌트
import glAbilitiesMixin from '~/vue_shared/mixins/gl_abilities_mixin';
export default {
// ...
mixins: [glAbilitiesMixin()],
// ...
created() {
if (this.glAbilities.someAbility) {
// ...
}
},
}
피처 플래그에 접근하기
피처 플래그를 프론트엔드에 푸시한 이후에는 provide
및 inject
메커니즘을 사용하여 Vue 응용 프로그램의 하위 컴포넌트에서 피처 플래그를 사용할 수 있게 합니다. glFeatures
객체는 이미 commons/vue.js
에서 제공되므로 플래그를 사용하려면 믹스인만 필요합니다:
// 임의의 하위 컴포넌트
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
// ...
mixins: [glFeatureFlagsMixin()],
// ...
created() {
if (this.glFeatures.myFlag) {
// ...
}
},
}
이 방식에는 몇 가지 이점이 있습니다:
- 임의로 깊게 중첩된 컴포넌트는 중간 컴포넌트가 알지 못해도 플래그를 선택적으로 사용하고 액세스할 수 있습니다 (예: 플래그를 props를 통해 전달하는 것과 비교).
-
vue-test-utils
에서mount
/shallowMount
로 플래그를 제공할 수 있기 때문에 좋은 테스트 가능성이 있습니다.import { shallowMount } from '@vue/test-utils'; shallowMount(component, { provide: { glFeatures: { myFlag: true }, }, });
- 글로벌 변수에 액세스할 필요가 없으며, entry point를 제외하고는 요구되지 않습니다.
페이지로 리디렉션하고 알림 표시하기
다른 페이지로 리디렉션하고 알림을 표시해야 하는 경우, visitUrlWithAlerts
유틸리티를 사용할 수 있습니다.
이는 새로 생성된 리소스로 리디렉션하고 성공 알림을 표시할 때 유용합니다.
기본적으로 페이지가 다시로드될 때 알림이 지워집니다. 특정 페이지에 알림을 유지하려면 persistOnPages
키를 Rails 컨트롤러 작업 배열로 설정할 수 있습니다. Rails 컨트롤러 작업을 확인하려면 콘솔에서 document.body.dataset.page
를 실행하세요.
예시:
visitUrlWithAlerts('/dashboard/groups', [
{
id: 'resource-building-in-background',
message: '리소스가 백그라운드에서 생성 중입니다.',
variant: 'info',
persistOnPages: ['dashboard:groups:index'],
},
])
지속적인 알림을 매뉴얼으로 제거해야 하는 경우, removeGlobalAlertById
유틸리티를 사용할 수 있습니다.
컴포넌트를 위한 폴더
이 폴더에는 이 새로운 기능에 특화된 모든 컴포넌트가 포함되어 있습니다.
다른 곳에서 사용할 가능성이 높은 컴포넌트를 사용하거나 만들려면 vue_shared/components
를 참조하세요.
컴포넌트를 만들어야 하는 때를 알아내는 좋은 가이드 라인은 해당 컴포넌트를 다른 곳에서 재사용할 수 있는지 여부를 생각해 보는 것입니다.
예를 들어, 테이블은 GitLab 전체에서 상당한 수의 장소에서 사용되므로 테이블은 좋은 컴포넌트가 될 것입니다. 그러나 특정 테이블에서만 사용되는 테이블 셀은 이 패턴의 좋은 사용 사례가 아닐 것입니다.
Vue.js 사이트의 Component System에서 컴포넌트에 대해 더 읽어볼 수 있습니다.
스토어를 위한 폴더
Vuex
더 많은 세부 정보는 이 페이지를 확인하세요.
Vue Router
페이지에 Vue Router를 추가하려면:
-
와일드카드인
*vueroute
를 사용하여 Rails 라우트 파일에 캐치 올 라우트를 추가하세요.# ee/config/routes/project.rb에서의 예시 resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index
위의 예시는
iteration_cadences
컨트롤러의index
페이지를path
의 시작 부분과 일치하는 모든 라우트에 제공합니다. 예를 들어groupname/projectname/-/cadences/123/456/
와 같은 라우트입니다. -
프론트엔드에서 기본 라우트(최종
*vueroute
이전의 모든 부분)를 Vue Router를 초기화할 때base
매개변수로 사용하기 위해 전달하세요..js-my-app{ data: { base_path: project_iteration_cadences_path(project) } }
-
라우터를 초기화하세요.
Vue.use(VueRouter); export function createRouter(basePath) { return new VueRouter({ routes: createRoutes(), mode: 'history', base: basePath, }); }
-
path: '*'
로 인식되지 않은 라우트에 대한 대체 방법을 추가하세요. 다음 중 하나:-
라우트 배열 끝에 리디렉션을 추가하세요.
const routes = [ { path: '/', name: 'list-page', component: ListPage, }, { path: '*', redirect: '/', }, ];
-
라우트 배열 끝에 대체 컴포넌트를 추가하세요.
const routes = [ { path: '/', name: 'list-page', component: ListPage, }, { path: '*', component: NotFound, }, ];
-
-
선택 사항. 자식 라우트에 대한 path 도우미를 사용하려면 부모 컨트롤러를 사용하려면
controller
및action
매개변수를 추가하세요.resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index do resources :iterations, only: [:index, :new, :edit, :show], constraints: { id: /\d+/ }, controller: :iteration_cadences, action: :index
이렇게 하면
/cadences/123/iterations/456/edit
와 같은 라우트가 백엔드에서 유효성을 검사할 수 있도록 하고, 그룹이나 프로젝트 멤버십을 확인할 수 있습니다. 또한 부모 컨트롤러를 사용하여_path
도우미를 사용할 수 있으므로 피처 스펙에서 경로의*vueroute
부분을 매뉴얼으로 빌드할 필요 없이 페이지를 로드할 수 있습니다.
Vue와 jQuery 혼합
- Vue와 jQuery를 혼합하는 것은 권장되지 않습니다.
- Vue에서 특정 jQuery 플러그인을 사용하려면 해당 플러그인을 래핑하세요.
- Vue가 기존 jQuery 이벤트를 jQuery 이벤트 리스너를 통해 수신하는 것은 허용됩니다.
- Vue에서 jQuery와 상호 작용하기 위해 새로운 jQuery 이벤트를 추가하는 것은 권장되지 않습니다.
Vue와 JavaScript 클래스 혼합 (데이터 함수 내에서)
Vue 문서에서 데이터 함수/객체는 다음과 같이 정의됩니다:
Vue 인스턴스의 데이터 객체입니다. Vue는 속성을 getter/setter로 재귀적으로 변환하여 “반응형”으로 만듭니다. 객체는 단순해야 합니다. 브라우저 API 객체나 프로토타입 속성과 같은 네이티브 객체는 무시됩니다. 가이드라인은 데이터는 데이터일 뿐이며, 자체적인 상태를 가진 객체를 관찰하는 것은 권장되지 않습니다.
Vue의 지침에 따르면:
- JavaScript 클래스를 사용하거나 생성하지 마십시오 데이터 함수.
- 새로운 JavaScript 클래스 구현을 추가하지 마십시오.
- 원시 또는 객체를 사용할 수 없는 경우 GraphQL, Vuex 또는 여러 컴포넌트를 사용하세요.
- 기존 구현은 위의 방법을 사용하여 유지하세요.
- 컴포넌트에 중대한 변경이 필요할 때 순수한 객체 모델로 컴포넌트를 이관하세요.
- 비즈니스 로직을 헬퍼 또는 유틸리티에 추가하여 해당 컴포넌트와 별도로 테스트할 수 있도록 하세요.
왜냐하면
거대한 코드베이스에서 JavaScript 클래스를 가지고 있는 것이 유지 관리에 어려움을 초래하는 추가적인 이유:
- 클래스가 생성된 후 Vue 반응성과 최선의 실천법에 영향을 미칠 수 있는 방식으로 확장될 수 있습니다.
- 클래스는 컴포넌트 API와 내부 작업을 덜 명확하게 만드는 추상화 계층을 추가합니다.
- 테스트하기 어려워집니다. 클래스가 컴포넌트 데이터 함수에서 인스턴스화되기 때문에 컴포넌트와 클래스를 ‘분리’하는 것이 더 어려워집니다.
- 기능 코드를 객체 지향 원칙(OOP)에 추가하면 코드 작성 방법이 또 다른 방식으로 추가되어 일관성과 명확성이 감소합니다.
스타일 가이드
Vue 컴포넌트 및 템플릿을 작성하고 테스트하는 데 최적의 방법에 대해서는 스타일 가이드의 Vue 섹션을 참조하세요.
Composition API
Vue 2.7에서는 Vue 컴포넌트 및 독립적인 컴포저블에 Composition API를 사용할 수 있습니다.
<script>
보다 <script setup>
선호
Composition API를 사용하면 컴포넌트의 로직을 <script>
섹션에 배치하거나 전용 <script setup>
섹션을 가지고 있을 수 있습니다. setup()
속성을 사용하여 컴포넌트에 Composition API를 추가하려면 <script>
을 사용해야 합니다.
<script>
import { computed } from 'vue';
export default {
name: 'MyComponent',
setup(props) {
const doubleCount = computed(() => props.count*2)
}
}
</script>
v-bind
제한 사항
네이티브 컨트롤 래퍼를 개발할 때를 제외하고는 가능한한 v-bind="$attrs"
사용을 피하십시오.
- 컴포넌트의 계약 손실.
props
는 특별히 이 문제를 해결하도록 설계되었습니다. - 트리 내의 각 컴포넌트에 대한 높은 유지 관리 비용.
v-bind="$attrs"
는 데이터 흐름을 이해하기 위해 컴포넌트 트리 전체를 스캔해야 하기 때문에 특히 디버깅하기 어렵습니다. - Vue 3로의 이전 중에 문제가 발생합니다. Vue 3의
$attrs
에는 예기치 않은 부작용을 초래할 수 있는 이벤트 리스너가 포함됩니다.
컴포넌트 당 하나의 API 스타일 사용을 목표로
setup()
속성을 Vue 컴포넌트에 추가할 때, 가독성과 유지 관리를 위해 해당 컴포넌트 전체적으로 Composition API로 리팩토링하는 것을 고려하세요. 특히 큰 컴포넌트인 경우 항상 가능한 것은 아니지만 가독성과 유지 관리를 위해 컴포넌트 당 하나의 API 스타일을 갖도록 노력해야 합니다.
컴포저블
Composition API로 인해 리액티브 상태를 포함한 로직을 추상화하는 새로운 방법을 가지고 있습니다. 컴포저블은 매개변수를 받고 Vue 컴포넌트에서 사용될 리액티브 속성과 메서드를 반환하는 함수입니다.
// useCount.js
import { ref } from 'vue';
export function useCount(initialValue) {
const count = ref(initialValue)
function incrementCount() {
count.value += 1
}
function decrementCount() {
count.value -= 1
}
return { count, incrementCount, decrementCount }
}
// MyComponent.vue
import { useCount } from 'useCount'
export default {
name: 'MyComponent',
setup() {
const { count, incrementCount, decrementCount } = useCount(5)
return { count, incrementCount, decrementCount }
}
}
use
로 함수 및 파일명 접두어 추가
Vue에서 컴포저블의 일반적인 명명 규칙은 use
로 접두어를 붙이고 간단하게 컴포저블 기능을 참조하는 것입니다 (useBreakpoints
, useGeolocation
등). .js
파일에 여러 개의 컴포저블이 포함되어 있더라도 파일명을 use_
로 시작하도록 동일한 규칙이 적용됩니다.
라이프사이클 함정 피하기
컴포저블을 작성할 때 가능한한 간단하게 유지하도록 노력해야 합니다. 라이프사이클 후크는 컴포저블에 복잡성을 추가하고 예상치 못한 부작용을 일으킬 수 있으므로 이를 피하기 위해서 다음 원칙에 따라야 합니다:
- 가능한 경우 라이프사이클 후크 사용을 최소화하고 콜백을 수락/반환하는 것을 선호하세요.
- 컴포저블이 라이프사이클 후크를 사용해야 하는 경우, 해당 컴포저블 내에서 동일한 컴포저블 내에서
onUnmounted
에서 제거하는지 확인하세요. - 항상 라이프사이클 후크를 즉시 설정하세요:
// bad
const useAsyncLogic = () => {
const action = async () => {
await doSomething();
onMounted(doSomethingElse);
};
return { action };
};
// OK
const useAsyncLogic = () => {
const done = ref(false);
onMounted(() => {
watch(
done,
() => done.value && doSomethingElse(),
{ immediate: true },
);
});
const action = async () => {
await doSomething();
done.value = true;
};
return { action };
};
탈출구 사용을 피하십시오
모든 것을 블랙 박스로 처리하는 콤포저블을 작성하고 Vue가 제공하는 탈출구 중 일부를 사용하는 것은 유혹적일 수 있습니다. 그러나 대부분의 경우에는 이러한 방법이 복잡하고 유지 보수가 어렵게 만듭니다. 하나의 탈출구는 getCurrentInstance
메서드입니다. 이 메서드는 현재 렌더링 컴포넌트의 인스턴스를 반환합니다. 이 메서드 대신에 데이터나 메서드를 인수를 통해 콤포저블에 전달하는 것을 선호해야 합니다.
const useSomeLogic = () => {
doSomeLogic();
getCurrentInstance().emit('done'); // 나쁨
};
const done = () => emit('done');
const useSomeLogic = (done) => {
doSomeLogic();
done(); // 좋음, 콤포저블이 지나치게 똑똑하게 되려고 하지 않습니다
}
콤포저블과 Vuex
콤포저블에서는 항상 Vuex 상태(state)를 사용하는 것을 피해야 합니다. 불가피한 경우에는 props를 사용하여 해당 상태를 받고, setup
에서 Vuex 상태를 업데이트하기 위해 이벤트를 발생시켜야 합니다. 부모 컴포넌트는 해당 상태를 Vuex에서 가져오고, 자식 컴포넌트에서 발생한 이벤트로 해당 상태를 변경해야 합니다. prop을 통해 내려온 상태를 절대로 변경해서는 안 됩니다. 콤포저블에서 Vuex 상태를 변경해야 하는 경우 콜백을 사용하여 이벤트를 발생시켜야 합니다.
const useAsyncComposable = ({ state, update }) => {
const start = async () => {
const newState = await doSomething(state);
update(newState);
};
return { start };
};
const ComponentWithComposable = {
setup(props, { emit }) {
const update = (data) => emit('update', data);
const state = computed(() => props.state); // Vuex에서 가져온 상태
const { start } = useAsyncComposable({ state, update });
start();
},
};
콤포저블 테스트
Vue 컴포넌트 테스트
Vue 테스팅 스타일 가이드(https://vuejs.org/guide/unit-testing.html)를 참조하여 Vue 컴포넌트를 테스트하는 지침과 모범 사례를 확인하세요.
각 Vue 컴포넌트에는 고유한 출력이 있습니다. 이 출력은 항상 렌더 함수에 있습니다.
Vue 컴포넌트의 각 메서드를 개별적으로 테스트할 수 있지만, 우리의 목표는 언제나 렌더 함수의 출력을 테스트하는 것입니다. 이것은 항상 상태를 나타냅니다.
도움이 필요하면 Vue 테스트 가이드를 방문하세요.
다음은 이 Vue 컴포넌트에 대한 잘 구조화된 단위 테스트의 예입니다.
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import axios from '~/lib/utils/axios_utils';
import App from '~/todos/app.vue';
const TEST_TODOS = [{ text: 'Lorem ipsum test text' }, { text: 'Lorem ipsum 2' }];
const TEST_NEW_TODO = 'New todo title';
const TEST_TODO_PATH = '/todos';
describe('~/todos/app.vue', () => {
let wrapper;
let mock;
beforeEach(() => {
// 중요: axios API 요청을 트랩하는 데 axios-mock-adapter를 사용합니다
mock = new MockAdapter(axios);
mock.onGet(TEST_TODO_PATH).reply(200, TEST_TODOS);
mock.onPost(TEST_TODO_PATH).reply(200);
});
afterEach(() => {
// 중요: axios mock 어댑터를 정리합니다
mock.restore();
});
// 컴포넌트의 설정과 협력자(예: Vuex 및 axios)를 분리하는 것이 매우 도움이 됩니다.
const createWrapper = (props = {}) => {
wrapper = shallowMountExtended(App, {
propsData: {
path: TEST_TODO_PATH,
...props,
},
});
};
// 도우미 메서드는 테스트의 유지 관리 및 가독성을 크게 도와줍니다.
const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findAddButton = () => wrapper.findByTestId('add-button');
const findTextInput = () => wrapper.findByTestId('text-input');
const findTodoData = () =>
wrapper
.findAllByTestId('todo-item')
.wrappers.map((item) => ({ text: item.text() }));
describe('마운트되었을 때와 로딩 중일 때', () => {
beforeEach(() => {
// 결코 해결되지 않을 요청을 생성합니다
mock.onGet(TEST_TODO_PATH).reply(() => new Promise(() => {}));
createWrapper();
});
it('로딩 상태를 렌더링해야 합니다', () => {
expect(findLoader().exists()).toBe(true);
});
});
describe('할 일이 로드된 경우', () => {
beforeEach(() => {
createWrapper();
// 중요: 이 컴포넌트는 마운트 시에 데이터를 비동기적으로 가져오므로 Vue 템플릿이 업데이트될 때까지 기다려야 합니다.
return wrapper.vm.$nextTick();
});
it('로딩 상태를 표시하지 않아야 합니다', () => {
expect(findLoader().exists()).toBe(false);
});
it('할 일을 렌더링해야 합니다', () => {
expect(findTodoData()).toEqual(TEST_TODOS);
});
it('할 일을 추가하면 새 할 일을 게시해야 합니다', async () => {
findTextInput().vm.$emit('update', TEST_NEW_TODO);
findAddButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(mock.history.post.map((x) => JSON.parse(x.data))).toEqual([{ text: TEST_NEW_TODO }]);
});
});
});
자식 컴포넌트
- 자식 컴포넌트가 렌더링되는 방식을 정의하는 지시문(예:
v-if
및v-for
)을 테스트합니다. - 자식 컴포넌트에 전달하는 props를 테스트합니다(특히 테스트 중인 컴포넌트에서 계산되는 prop의 경우, 예:
computed
속성을 사용하여)..props()
를 사용하고.vm.someProp
을 사용하지 않도록 주의하세요. - 자식 컴포넌트에서 발생한 이벤트에 올바르게 반응하는지 확인합니다:
const checkbox = wrapper.findByTestId('checkboxTestId');
expect(checkbox.attributes('disabled')).not.toBeDefined();
findChildComponent().vm.$emit('primary');
await nextTick();
expect(checkbox.attributes('disabled')).toBeDefined();
- 절대로 자식 컴포넌트의 내부 구현을 테스트하지 않습니다:
// 나쁨
expect(findChildComponent().find('.error-alert').exists()).toBe(false);
// 좋음
expect(findChildComponent().props('withAlertContainer')).toBe(false);
이벤트
우리는 컴포넌트에서 액션에 대한 응답으로 발생한 이벤트를 테스트해야 합니다. 이 테스트는 올바른 이벤트가 올바른 인수와 함께 발생하는지 확인합니다.
모든 기본 DOM 이벤트에 대해서는 trigger
를 사용하여 이벤트를 발생시켜야 합니다.
// SomeButton이 <button>Some button</button>를 렌더링한다고 가정
wrapper = mount(SomeButton);
...
it('클릭 이벤트를 발생해야 합니다', () => {
const btn = wrapper.find('button')
btn.trigger('click');
...
})
Vue 이벤트를 발생시킬 때에는 emit
를 사용합니다.
wrapper = shallowMount(DropdownItem);
...
it('itemClicked 이벤트를 발생해야 합니다', () => {
DropdownItem.vm.$emit('itemClicked');
...
})
우리는 이벤트가 발생했는지를 확인하기 위해 emitted()
메서드의 결과에 대한 단언을 해야 합니다.
자식 컴포넌트에서 이벤트를 발생시킬 때에는 trigger
대신에 vm.$emit
을 사용하는 것이 좋은 실천입니다.
컴포넌트에서 trigger
를 사용하는 것은 해당 컴포넌트를 white box로 취급하는 것을 의미합니다: 우리는 자식 컴포넌트의 루트 요소가 기본 click
이벤트를 가지고 있다고 가정합니다. 또한, Vue3 모드에서 trigger
를 사용할 때 일부 테스트가 실패하는 경우도 있습니다.
const findButton = () => wrapper.findComponent(GlButton);
// bad
findButton().trigger('click');
// good
findButton().vm.$emit('click');
Vue.js 전문가 역할
Vue.js 전문가로 지원하려면 다음을 보여주는 경우에만 지원해야 합니다:
- Vue 및 Vuex 반응성에 대한 심층적인 이해
- Vue 및 Vuex 코드가 공식 가이드라인과 우리의 가이드라인에 따라 구조화되어 있음
- Vue 및 Vuex 애플리케이션의 테스트에 대한 완전한 이해
- Vuex 코드가 문서화된 패턴을 따름
- 기존 Vue 및 Vuex 애플리케이션 및 기존 재사용 가능한 컴포넌트에 대한 지식
Vue 2 -> Vue 3 마이그레이션
- 이 섹션은 Vue 2.x에서 코드베이스를 Vue 3.x로 마이그레이션하는 노력을 지원하기 위해 임시로 추가되었습니다.
코드베이스에 특정 기능을 추가하지 않고 기술 부채가 마이그레이션을 위해 늘어나는 것을 방지하기 위해 다음을 최소화하는 것을 권장합니다:
- 필터;
- 이벤트 버스;
- 기능적 템플릿
-
slot
속성
자세한 내용은 Vue 3로의 마이그레이션에서 확인할 수 있습니다.
부록 - 테스트 대상 Vue 컴포넌트
이것은 Vue 컴포넌트 테스트 섹션에서 테스트되는 예제 컴포넌트의 템플릿입니다:
<template>
<div class="content">
<gl-loading-icon v-if="isLoading" />
<template v-else>
<div
v-for="todo in todos"
:key="todo.id"
:class="{ 'gl-strike': todo.isDone }"
data-testid="todo-item"
>{{ toddo.text }}</div>
<footer class="gl-border-t-1 gl-mt-3 gl-pt-3">
<gl-form-input
type="text"
v-model="todoText"
data-testid="text-input"
>
<gl-button
variant="confirm"
data-testid="add-button"
@click="addTodo"
>Add</gl-button>
</footer>
</template>
</div>
</template>