Vue
GitLab v19.4요약
Vue를 시작하려면 Vue 공식 문서를 참고합니다. 다음 섹션에서 설명하는 내용은 아래 예시에서 확인할 수 있습니다: 경우에 따라 HAML 페이지만으로도 요구 사항을 충족할 수 있습니다. 이를 더 잘 설명하기 위해, 토글이 하나 있고 그 토글을 누르면 API 요청이 전송되는 페이지를 예로 듭니다.
Vue를 시작하려면 Vue 공식 문서를 참고합니다.
예시#
다음 섹션에서 설명하는 내용은 아래 예시에서 확인할 수 있습니다:
Vue 애플리케이션을 추가해야 하는 경우#
경우에 따라 HAML 페이지만으로도 요구 사항을 충족할 수 있습니다. 이는 주로 정적 페이지나 로직이 거의 없는 페이지에 해당합니다. 페이지에 Vue 애플리케이션을 추가할 가치가 있는지는 "애플리케이션 상태를 유지하고 렌더링된 페이지를 그 상태와 동기화해야 하는지"를 기준으로 판단합니다.
이를 더 잘 설명하기 위해, 토글이 하나 있고 그 토글을 누르면 API 요청이 전송되는 페이지를 예로 듭니다. 이 경우에는 유지해야 할 상태가 없으므로 요청을 보내고 토글을 전환하기만 하면 됩니다. 그러나 항상 첫 번째 토글과 반대 상태여야 하는 토글을 하나 더 추가한다면 상태가 필요해집니다. 즉 하나의 토글이 다른 토글의 상태를 "인식"해야 합니다. 순수 JavaScript로 작성하면 이 로직은 보통 DOM 이벤트를 수신하고 DOM을 수정하는 방식으로 처리됩니다. 이런 경우는 Vue.js로 훨씬 쉽게 처리할 수 있으므로 여기서는 Vue 애플리케이션을 만들어야 합니다.
페이지에 Vue 애플리케이션을 추가하는 방법#
- Vue 애플리케이션을 위해
app/assets/javascripts에 새 폴더를 만듭니다. - 애플리케이션을 로드하기 위해 페이지별 JavaScript를 추가합니다.
- `initSimpleApp helper를 사용하면 HAML에서 JS로 데이터를 전달하는 과정을 간소화할 수 있습니다.
Vue 애플리케이션이 필요하다는 신호#
- 여러 요소에 기반한 복잡한 조건을 정의하고 사용자 상호작용에 따라 업데이트해야 할 때;
- 어떤 형태로든 애플리케이션 상태를 유지하고 태그·요소 간에 공유해야 할 때;
- 앞으로 복잡한 로직이 추가될 것으로 예상될 때 - 다음 단계에서 JS·HAML을 Vue로 다시 작성하는 것보다 기본 Vue 애플리케이션으로 시작하는 쪽이 더 쉽습니다.
페이지에 여러 Vue 애플리케이션을 두는 것은 지양#
과거에는 렌더링된 HAML 페이지의 여러 부분에 소규모 Vue 애플리케이션을 여러 개 추가하는 방식으로 페이지에 조각조각 인터랙션을 더했습니다. 그러나 이 방식은 여러 가지 복잡한 문제를 낳았습니다:
- 대부분의 경우 이러한 애플리케이션은 상태를 공유하지 않고 API 요청을 독립적으로 수행하므로 요청 수가 증가합니다;
- Rails에서 Vue로 데이터를 제공하려면 여러 엔드포인트를 사용해야 합니다;
- 페이지 로드 후 Vue 애플리케이션을 동적으로 렌더링할 수 없으므로 페이지 구조가 경직됩니다;
- 클라이언트 사이드 라우팅을 활용해 Rails 라우팅을 대체할 수 없습니다;
- 여러 애플리케이션은 예측하기 어려운 사용자 경험, 늘어난 페이지 복잡도, 더 어려운 디버깅 과정으로 이어집니다;
- 앱들이 서로 통신하는 방식이 Web Vitals 수치에 영향을 미칩니다.
이러한 이유로, 이미 다른 Vue 애플리케이션이 있는 페이지에는 새 Vue 애플리케이션을 추가할 때 신중해야 합니다(이는 기존 또는 새 내비게이션에는 해당하지 않습니다). 새 앱을 추가하기 전에, 기존 애플리케이션을 확장해 원하는 기능을 구현하는 것이 절대적으로 불가능한지 확인합니다. 판단이 서지 않으면 #frontend 또는 #frontend-maintainers Slack 채널에서 아키텍처 조언을 구합니다.
그래도 새 애플리케이션을 추가해야 한다면, 기존 애플리케이션과 로컬 상태를 공유하도록 합니다. 참고: 어떤 상태 관리자를 사용해야 하는가
Vue 아키텍처#
Vue 아키텍처로 달성하려는 주요 목표는 데이터 흐름과 데이터 진입점을 각각 하나로 두는 것입니다. 이 목표를 달성하기 위해 Pinia 또는 Apollo Client를 사용합니다.
이 아키텍처에 대해서는 Vue 문서의 상태 관리 및 단방향 데이터 흐름에서도 알아볼 수 있습니다.
컴포넌트와 Store#
Vue.js로 구현된 일부 기능, 예를 들어 이슈 보드 나 환경 테이블에서는 명확한 관심사 분리를 확인할 수 있습니다:
new_feature
├── components
│ └── component.vue
│ └── ...
├── store
│ └── new_feature_store.js
├── index.js
일관성을 위해 동일한 구조를 따르는 것을 권장합니다.
각각을 살펴봅니다:
index.js 파일#
이 파일은 새 기능의 인덱스 파일입니다. 새 기능의 루트 Vue 인스턴스 가 여기에 있어야 합니다.
Store와 Service는 이 파일에서 가져와 초기화한 다음 메인 컴포넌트에 prop으로 전달해야 합니다.
페이지별 JavaScript를 반드시 참고합니다.
부트스트래핑 시 주의할 점#
HAML에서 JavaScript로 데이터 전달#
Vue 애플리케이션을 마운트하는 동안 Rails에서 JavaScript로 데이터를 전달해야 할 수 있습니다.
이를 위해 HTML 요소의 data 속성을 사용하고 애플리케이션을 마운트하는 동안 이를 조회할 수 있습니다.
이 작업은 애플리케이션을 초기화하는 동안에만 수행해야 합니다. 마운트된 요소가 Vue가 생성한 DOM으로
대체되기 때문입니다.
data 속성은 문자열 값만 받을 수 있으므로,
다른 변수 타입은 문자열로 캐스팅하거나 변환해야 합니다.
메인 Vue 컴포넌트 내부에서 DOM을 조회하는 대신 render 함수의 props나
provide를 통해 DOM에서 Vue 인스턴스로 데이터를 전달하면
단위 테스트에서 픽스처나 HTML 요소를 만들 필요가 없다는 장점이 있습니다.
initSimpleApp 헬퍼#
initSimpleApp은 Vue.js에서 컴포넌트를 마운트하는 과정을 간소화하는 헬퍼 함수입니다. HTML의 마운트 지점을 나타내는 선택자 문자열과 Vue 컴포넌트, 이렇게 두 개의 인수를 받습니다.
initSimpleApp 사용 방법:
- ID나 고유한 클래스가 있는 HTML 요소를 페이지에 포함합니다.
- JSON 객체를 담은 data-view-model 속성을 추가합니다.
- 원하는 Vue 컴포넌트를 가져와, HTML 요소를 선택하는 유효한 CSS 선택자 문자열과 함께
initSimpleApp에 전달합니다. 이 문자열은 지정한 위치에 컴포넌트를 마운트합니다.
initSimpleApp은 data-view-model 속성의 내용을 JSON 객체로 자동으로 가져와 마운트된 Vue 컴포넌트에 props로 전달합니다. 이를 활용해 컴포넌트에 데이터를 미리 채울 수 있습니다.
예시:
//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, { name: 'MyAppRoot' })
props 대신 provide/inject로 값 전달
initSimpleApp을 사용해 props 대신 provide/inject로 값을 전달하려면:
- ID나 고유한 클래스가 있는 HTML 요소를 페이지에 포함합니다.
- JSON 객체를 담은
data-provide속성을 추가합니다. - 원하는 Vue 컴포넌트를 가져와, HTML 요소를 선택하는 유효한 CSS 선택자 문자열과 함께
initSimpleApp에 전달합니다. 이 문자열은 지정한 위치에 컴포넌트를 마운트합니다.
initSimpleApp은 data-provide 속성의 내용을 JSON 객체로 자동으로 가져와 마운트된 Vue 컴포넌트에 inject로 전달합니다. 이를 활용해 컴포넌트에 데이터를 미리 채울 수 있습니다.
예시:
//my_component.vue
<template>
<div>
<p>Inject1: {{ inject1 }}</p>
<p>Inject2: {{ inject2 }}</p>
</div>
</template>
<script>
export default {
name: 'MyComponent',
inject: {
inject1: {
default: '',
},
inject2: {
default: 0
}
},
}
</script>
<div id="js-my-element" data-provide='{"inject1": "my object", "inject2": 42 }'></div>
//index.js
import MyComponent from './my_component.vue'
import { initSimpleApp } from '~/helpers/init_simple_app_helper'
initSimpleApp('#js-my-element', MyComponent, { name: 'MyAppRoot' })
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에서 값을 전달하는 데 의존성 주입을 사용하는 것이 이상적입니다:
- 주입된 값에 데이터 타입이나 내용에 대한 명시적인 검증이 필요하지 않은 경우.
- 값이 반응형일 필요가 없는 경우.
- 계층 구조에 이 값에 접근해야 하는 컴포넌트가 여러 개 있어 prop 드릴링이 불편해지는 경우. prop 드릴링이란 실제로 이를 사용하는 컴포넌트에 도달할 때까지 계층 구조의 모든 컴포넌트에 동일한 prop을 전달하는 방식을 말합니다.
다음 두 조건이 모두 참이면 의존성 주입이 하위 컴포넌트(직계 하위이든 여러 단계 아래이든)를 깨뜨릴 수 있습니다:
inject설정에 선언된 값에 기본값이 정의되어 있지 않은 경우.- 부모 컴포넌트가
provide설정으로 값을 전달하지 않은 경우.
상황에 맞는 경우라면 기본값이 유용할 수 있습니다.
의존성 주입은 시간이 지나면서 낡아갑니다.
inject를 사용하는 컴포넌트가 리팩터링되거나 제거되어도, 대응하는 provide 항목은
죽은 배관(dead plumbing)으로 그대로 남는 경우가 많습니다.
아무 컴포넌트도 주입받지 않는 provide를 찾아 제거하는 방법은
사용되지 않는 Vue 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
},
});
},
});
Vue 애플리케이션을 마운트하기 위해 id 속성을 추가할 때는, 이 id가
코드베이스 전체에서 고유한지 확인합니다.
Vue 앱에 전달되는 데이터를 명시적으로 선언하는 이유에 대한 자세한 내용은 Vue 스타일 가이드를 참고합니다.
Vue 애플리케이션에 Rails 폼 필드 전달#
Rails로 폼을 구성할 때 폼 입력의 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 객체를 목(mock)으로
만들지 않아도 되어 테스트가 쉬워집니다. 이 작업은 Vue 인스턴스를
초기화하는 동안 수행해야 하며, 데이터는 메인 컴포넌트에 props로 전달해야 합니다:
return new Vue({
el: '.js-vue-app',
name: 'MyComponentRoot',
render(createElement) {
return createElement('my-component', {
props: {
avatarUrl: gl.avatarUrl,
},
});
},
});
능력(ability) 접근#
능력(ability)을 프론트엔드로 전달한 뒤에는
Vue의 provide와 inject
메커니즘을 사용해 Vue 애플리케이션의 모든 하위 컴포넌트에서 능력을 사용할 수 있게 합니다. glAbilities 객체는
이미 commons/vue.js에서 제공되므로, 플래그를 사용하려면
믹스인만 있으면 됩니다:
// An arbitrary descendant component
import glAbilitiesMixin from '~/vue_shared/mixins/gl_abilities_mixin';
export default {
// ...
mixins: [glAbilitiesMixin()],
// ...
created() {
if (this.glAbilities.someAbility) {
// ...
}
},
}
기능 플래그 접근#
기능 플래그를 프론트엔드로 전달한 뒤에는
Vue의 provide와 inject
메커니즘을 사용해 Vue 애플리케이션의 모든 하위 컴포넌트에서 기능 플래그를 사용할 수 있게 합니다. glFeatures 객체는
이미 commons/vue.js에서 제공되므로, 플래그를 사용하려면
믹스인만 있으면 됩니다:
// An arbitrary descendant component
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에 prop으로 전달할 수 있기 때문입니다.import { shallowMount } from '@vue/test-utils'; shallowMount(component, { provide: { glFeatures: { myFlag: true }, }, }); -
애플리케이션의 진입점을 제외하면 전역 변수에 접근할 필요가 없습니다.
페이지 리다이렉트 및 알림 표시#
다른 페이지로 리다이렉트하고 알림을 표시해야 하는 경우 visitUrlWithAlerts 유틸리티 함수를 사용할 수 있습니다.
새로 생성된 리소스로 리다이렉트하면서 성공 알림을 표시할 때 유용합니다.
기본적으로 알림은 페이지가 다시 로드될 때 사라집니다. 페이지에 알림을 유지해야 하는 경우
persistOnPages 키를 Rails 컨트롤러 액션의 배열로 설정할 수 있습니다. Rails 컨트롤러 액션을 확인하려면 콘솔에서 document.body.dataset.page를 실행합니다.
예시:
visitUrlWithAlerts('/dashboard/groups', [
{
id: 'resource-building-in-background',
message: 'Resource is being built in the background.',
variant: 'info',
persistOnPages: ['dashboard:groups:index'],
},
])
유지된 알림을 수동으로 제거해야 하는 경우 removeGlobalAlertById 유틸리티 함수를 사용할 수 있습니다.
프로그래밍 방식으로 알림을 닫아야 하는 경우 dismissGlobalAlertById 유틸리티 함수를 사용할 수 있습니다.
컴포넌트 폴더#
이 폴더에는 이 새 기능에 특화된 모든 컴포넌트가 있습니다.
다른 곳에서도 사용될 가능성이 있는 컴포넌트를 사용하거나 만들려면
vue_shared/components를 참고합니다.
컴포넌트를 언제 만들어야 하는지 판단하는 좋은 기준은 다른 곳에서도 재사용할 수 있는지를 생각해보는 것입니다.
예를 들어 테이블은 GitLab 전체에서 꽤 많은 곳에 쓰이므로 테이블은 컴포넌트로 적합합니다. 반면 하나의 테이블에서만 쓰이는 테이블 셀은 이 패턴을 쓰기에 적합하지 않습니다.
컴포넌트에 대한 자세한 내용은 Vue.js 사이트의 컴포넌트 시스템 섹션을 참고합니다.
Pinia#
Vuex#
Vuex는 사용 중단(deprecated)되었습니다. 마이그레이션을 검토합니다.
Vue Router#
페이지에 Vue Router를 추가하려면:
-
*vueroute라는 와일드카드를 사용해 Rails 라우트 파일에 catch-all 라우트를 추가합니다:# example from ee/config/routes/project.rb resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index위 예시는
path의 시작 부분과 일치하는 모든 라우트(예:groupname/projectname/-/cadences/123/456/)에iteration_cadences컨트롤러의index페이지를 제공합니다. -
Vue Router를 초기화할 때
base파라미터로 사용할 기본 라우트(*vueroute앞의 모든 부분)를 프론트엔드에 전달합니다:.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 helper를 사용할 수 있도록 하려면
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 end이렇게 하면
/cadences/123/iterations/456/edit같은 라우트를 백엔드에서 검증할 수 있습니다. 예를 들어 그룹이나 프로젝트 멤버십을 확인할 수 있습니다. 또한_pathhelper를 사용할 수 있으므로*vueroute부분을 수동으로 만들지 않고도 기능 스펙에서 페이지를 로드할 수 있습니다.
Vue와 jQuery 혼용#
- Vue와 jQuery를 혼용하는 것은 권장하지 않습니다.
- Vue에서 특정 jQuery 플러그인을 사용하려면 그 주변에 래퍼를 만듭니다.
- Vue가 jQuery 이벤트 리스너로 기존 jQuery 이벤트를 수신하는 것은 허용됩니다.
- Vue가 jQuery와 상호작용하기 위해 새 jQuery 이벤트를 추가하는 것은 권장하지 않습니다.
Vue와 JavaScript 클래스 혼용(data 함수에서)#
Vue 문서에서는 Data 함수·객체를 다음과 같이 정의합니다:
Vue 인스턴스의 data 객체입니다. Vue는 이 속성들을 재귀적으로 getter/setter로 변환하여 "반응형(reactive)"으로 만듭니다. 이 객체는 일반 객체여야 합니다. 브라우저 API 객체 같은 네이티브 객체와 프로토타입 속성은 무시됩니다. 데이터는 그저 데이터일 뿐이어야 한다는 것이 가이드라인이며, 자체적인 상태 동작을 가진 객체를 관찰하는 것은 권장하지 않습니다.
Vue 가이드라인에 따르면:
- data 함수에서 JavaScript 클래스를 사용하거나 만들지 않습니다.
- 새 JavaScript 클래스 구현을 추가하지 않습니다.
- 복잡한 상태 관리는 응집력 있고 분리된 컴포넌트나 상태 관리자로 캡슐화합니다.
- 이러한 방식을 사용하는 기존 구현은 유지합니다.
- 컴포넌트에 상당한 변경이 있을 때는 순수 객체 모델로 마이그레이션합니다.
- 비즈니스 로직은 별도 파일로 옮겨 컴포넌트와 분리해 테스트할 수 있도록 합니다.
이유#
거대한 코드베이스에서 JavaScript 클래스가 유지보수 문제를 일으키는 추가적인 이유는 다음과 같습니다:
- 클래스가 만들어지면 Vue 반응성과 모범 사례를 침해하는 방식으로 확장될 수 있습니다.
- 클래스는 추상화 계층을 추가하여 컴포넌트 API와 내부 동작을 덜 명확하게 만듭니다.
- 테스트하기가 더 어려워집니다. 클래스가 컴포넌트의 data 함수에서 인스턴스화되므로 컴포넌트와 클래스를 따로 '관리'하기가 어렵습니다.
- 함수형 코드베이스에 객체 지향 원칙(OOP)을 추가하면 코드 작성 방식이 하나 더 생겨 일관성과 명확성이 줄어듭니다.
스타일 가이드#
Vue 컴포넌트와 템플릿을 작성하고 테스트할 때의 모범 사례는 스타일 가이드의 Vue 섹션을 참고합니다.
Composition API#
Vue 2.7부터는 Vue 컴포넌트와 독립형 composable에서 Composition API를 사용할 수 있습니다.
<script setup>보다 <script> 사용을 권장#
Composition API를 사용하면 로직을 컴포넌트의 <script> 섹션에 배치하거나 전용 <script setup> 섹션을 둘 수 있습니다. <script>를 사용하고 setup() 속성으로 컴포넌트에 Composition API를 추가해야 합니다:
<script>
import { computed } from 'vue';
export default {
name: 'MyComponent',
setup(props) {
const doubleCount = computed(() => props.count*2)
}
}
</script>
v-bind 제한 사항#
절대적으로 필요한 경우가 아니면 v-bind="$attrs" 사용을 피합니다. 네이티브 컨트롤 래퍼를
개발할 때 필요할 수 있습니다. (이는 gitlab-ui 컴포넌트로 만들기 좋은 후보입니다.)
그 밖의 경우에는 항상 props와 명시적인 데이터 흐름을 사용하는 것을 우선합니다.
v-bind="$attrs"를 사용하면 다음과 같은 문제가 생깁니다:
- 컴포넌트 계약의 손실입니다.
props는 바로 이 문제를 해결하기 위해 설계되었습니다. - 트리의 각 컴포넌트에 유지보수 비용이 높습니다.
v-bind="$attrs"는 데이터 흐름을 이해하려면 컴포넌트 계층 구조 전체를 훑어봐야 하므로 디버깅하기가 특히 어렵습니다. - Vue 3 마이그레이션 과정에서 문제가 생길 수 있습니다. Vue 3의
$attrs에는 이벤트 리스너가 포함되어 있어 마이그레이션 완료 후 예상치 못한 부작용이 발생할 수 있습니다.
컴포넌트당 하나의 API 스타일을 지향#
Vue 컴포넌트에 setup() 속성을 추가할 때는 Composition API로 완전히 리팩터링하는 것을 검토합니다. 특히 대규모 컴포넌트에서는 항상 가능하지는 않지만, 가독성과 유지보수성을 위해 컴포넌트당 하나의 API 스타일을 지향해야 합니다.
Composable#
Composition API를 사용하면 반응형 상태를 포함한 로직을 _composable_로 추상화하는 새로운 방법이 생깁니다. composable은 파라미터를 받아 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에서 composable의 일반적인 명명 규칙은 use 접두사를 붙인 다음 composable의 기능을 간략히 나타내는 것입니다(예: useBreakpoints, useGeolocation). composable을 담은 .js 파일에도 같은 규칙이 적용됩니다. 파일에 composable이 여러 개 있어도 파일명은 use_로 시작해야 합니다.
라이프사이클 함정 피하기#
composable을 만들 때는 가능한 한 단순하게 유지하는 것을 지향해야 합니다. 라이프사이클 훅은 composable에 복잡성을 더하고 예상치 못한 부작용을 일으킬 수 있습니다. 이를 피하려면 다음 원칙을 따라야 합니다:
- 가능하면 라이프사이클 훅 사용을 최소화하고, 콜백을 받거나 반환하는 방식을 우선합니다.
- composable에 라이프사이클 훅이 필요하다면 정리(cleanup) 작업도 반드시 수행합니다.
onMounted에서 리스너를 추가했다면 같은 composable 안의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 };
};
탈출구 피하기#
모든 것을 블랙박스로 처리하는 composable을 만들고 Vue가 제공하는 탈출구를 활용하고 싶은 유혹이 생길 수 있습니다. 그러나 대부분의 경우 이렇게 하면 지나치게 복잡해지고 유지보수가 어려워집니다. 탈출구 중 하나는 getCurrentInstance 메서드입니다. 이 메서드는 현재 렌더링 중인 컴포넌트의 인스턴스를 반환합니다. 이 메서드를 사용하는 대신 데이터나 메서드를 인수로 composable에 전달하는 방식을 우선해야 합니다.
const useSomeLogic = () => {
doSomeLogic();
getCurrentInstance().emit('done'); // bad
};
const done = () => emit('done');
const useSomeLogic = (done) => {
doSomeLogic();
done(); // good, composable doesn't try to be too smart
}
Composable 테스트#
Vue 컴포넌트 테스트#
Vue 컴포넌트를 테스트하는 가이드라인과 모범 사례는 Vue 테스팅 스타일 가이드를 참고합니다.
각 Vue 컴포넌트에는 고유한 출력이 있습니다. 이 출력은 항상 render 함수에 있습니다.
Vue 컴포넌트의 각 메서드를 개별적으로 테스트할 수는 있지만, 우리의 목표는 항상 상태를 나타내는 render 함수의 출력을 테스트하는 것입니다.
자세한 내용은 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(() => {
// IMPORTANT: Use axios-mock-adapter for stubbing axios API requests
mock = new MockAdapter(axios);
mock.onGet(TEST_TODO_PATH).reply(200, TEST_TODOS);
mock.onPost(TEST_TODO_PATH).reply(200);
});
afterEach(() => {
// IMPORTANT: Clean up the axios mock adapter
mock.restore();
});
// It is very helpful to separate setting up the component from
// its collaborators (for example, Vuex and axios).
const createWrapper = (props = {}) => {
wrapper = shallowMountExtended(App, {
propsData: {
path: TEST_TODO_PATH,
...props,
},
});
};
// Helper methods greatly help test maintainability and readability.
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('when mounted and loading', () => {
beforeEach(() => {
// Create request which will never resolve
mock.onGet(TEST_TODO_PATH).reply(() => new Promise(() => {}));
createWrapper();
});
it('should render the loading state', () => {
expect(findLoader().exists()).toBe(true);
});
});
describe('when todos are loaded', () => {
beforeEach(() => {
createWrapper();
// IMPORTANT: This component fetches data asynchronously on mount, so let's wait for the Vue template to update
return wrapper.vm.$nextTick();
});
it('should not show loading', () => {
expect(findLoader().exists()).toBe(false);
});
it('should render todos', () => {
expect(findTodoData()).toEqual(TEST_TODOS);
});
it('when todo is added, should post new todo', 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를 테스트합니다(특히 테스트 대상 컴포넌트에서
computed속성처럼 계산되는 prop인 경우)..vm.someProp이 아니라.props()를 사용해야 합니다. -
하위 컴포넌트에서 발생하는 이벤트에 올바르게 반응하는지 테스트합니다:
const checkbox = wrapper.findByTestId('checkboxTestId'); expect(checkbox.attributes('disabled')).not.toBeDefined(); findChildComponent().vm.$emit('primary'); await nextTick(); expect(checkbox.attributes('disabled')).toBeDefined(); -
하위 컴포넌트의 내부 구현은 테스트하지 않습니다:
// bad expect(findChildComponent().find('.error-alert').exists()).toBe(false); // good expect(findChildComponent().props('withAlertContainer')).toBe(false);
이벤트#
컴포넌트에서 어떤 동작에 반응해 발생하는 이벤트를 테스트해야 합니다. 이 테스트는 올바른 이벤트가 올바른 인수와 함께 발생하는지 검증합니다.
네이티브 DOM 이벤트의 경우 trigger를 사용해
이벤트를 발생시켜야 합니다.
// Assuming SomeButton renders: <button>Some button</button>
wrapper = mount(SomeButton);
...
it('should fire the click event', () => {
const btn = wrapper.find('button')
btn.trigger('click');
...
})
Vue 이벤트를 발생시킬 때는 emit을 사용합니다.
wrapper = shallowMount(DropdownItem);
...
it('should fire the itemClicked event', () => {
DropdownItem.vm.$emit('itemClicked');
...
})
이벤트가 발생했는지는 다음 emitted() 메서드의
결과를 검증하여 확인해야 합니다.
하위 컴포넌트에서 이벤트를 발생시킬 때는 trigger보다 vm.$emit을 사용하는 것이 좋은 방법입니다.
컴포넌트에 trigger를 사용하는 것은 그 컴포넌트를 화이트박스로 취급한다는 뜻입니다. 하위 컴포넌트의 루트 요소에 네이티브 click 이벤트가 있다고 가정하는 것입니다. 또한 하위 컴포넌트에 trigger를 사용하면 일부 테스트가 Vue 3 모드에서 실패합니다.
const findButton = () => wrapper.findComponent(GlButton);
// bad
findButton().trigger('click');
// good
findButton().vm.$emit('click');
Vue.js 전문가 역할#
자신의 머지 리퀘스트와 리뷰가 다음을 보여줄 때에만 Vue.js 전문가로 신청해야 합니다:
- Vue 반응성에 대한 깊은 이해
- Vue와 Pinia 코드가 공식 가이드라인과 자체 가이드라인 모두에 따라 구조화되어 있음
- Vue 컴포넌트와 Pinia store 테스트에 대한 완전한 이해
- 기존 Vue·Pinia 애플리케이션과 기존 재사용 가능한 컴포넌트에 대한 지식
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"
>{{ todo.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>