- 예제
- Vue 애플리케이션을 추가해야 할 때
- 페이지에 여러 Vue 애플리케이션을 피하세요
- Vue 아키텍처
- 스타일 가이드
- 컴포지션 API
- Vue 컴포넌트 테스트
- Vue.js 전문가 역할
- Vue 2 -> Vue 3 마이그레이션
- 부록 - 테스트 중인 Vue 컴포넌트 주제
Vue
Vue를 시작하려면 그들의 문서를 읽어보세요.
예제
다음 섹션에 설명된 내용은 다음 예제에서 찾을 수 있습니다:
Vue 애플리케이션을 추가해야 할 때
가끔 HAML 페이지가 요구 사항을 만족하기에 충분합니다. 이 진술은 주로 정적 페이지나 논리가 거의 없는 페이지에 해당합니다. 페이지에 Vue 애플리케이션을 추가할 가치가 있는지 어떻게 알 수 있을까요? 답은 “애플리케이션 상태를 유지하고 렌더링된 페이지와 동기화해야 할 때”입니다.
이를 더 잘 설명하기 위해, 하나의 토글이 있는 페이지를 상상해보겠습니다. 이 토글을 전환하면 API 요청이 전송됩니다. 이 경우 유지하고자 하는 상태가 없으므로 요청을 보내고 토글을 전환합니다. 그러나 첫 번째 토글과 항상 반대가 되어야 하는 또 다른 토글을 추가한다면, 우리는 _상태_가 필요합니다: 하나의 토글이 다른 토글의 상태를 “인식”해야 합니다. 일반 JavaScript로 작성할 경우, 이 논리는 보통 DOM 이벤트를 듣고 DOM을 수정하는 것으로 처리됩니다. 이러한 경우는 Vue.js로 처리하는 것이 훨씬 쉽기 때문에 여기에서 Vue 애플리케이션을 생성해야 합니다.
Vue 애플리케이션이 필요할 수도 있음을 나타내는 플래그는 무엇입니까?
- 여러 요소에 기반한 복잡한 조건을 정의하고 사용자 상호작용에 따라 업데이트 해야 할 때;
- 애플리케이션 상태의 어떤 형태를 유지하고 태그/요소 간에 공유해야 할 때;
- 향후 복잡한 논리가 추가될 것으로 예상될 때 - 다음 단계에서 JS/HAML을 Vue로 다시 작성해야 하는 것보다 기본 Vue 애플리케이션으로 시작하는 것이 더 쉽습니다.
페이지에 여러 Vue 애플리케이션을 피하세요
과거에는 페이지에 점진적으로 상호작용을 추가하며 서로 다른 HAML 페이지의 여러 부분에 여러 개의 작은 Vue 애플리케이션을 추가했습니다. 그러나 이 접근 방식은 여러 가지 복잡성을 초래했습니다:
- 대부분의 경우, 이러한 애플리케이션은 상태를 공유하지 않으며 독립적으로 API 요청을 수행하여 요청 수가 증가합니다;
- 여러 엔드포인트를 사용하여 Rails에서 Vue로 데이터를 제공해야 합니다;
- 페이지 로드 후 Vue 애플리케이션을 동적으로 렌더링할 수 없어 페이지 구조가 경직됩니다;
- Rails 라우팅을 대체하기 위해 클라이언트 측 라우팅을 완전히 활용할 수 없습니다;
- 여러 애플리케이션은 예측할 수 없는 사용자 경험을 초래하고 페이지 복잡성을 증가시키며 디버깅 과정을 더 어렵게 만듭니다;
- 앱이 서로 통신하는 방식은 Web Vitals 수치에 영향을 미칩니다.
이러한 이유로 인해, 다른 Vue 애플리케이션이 이미 존재하는 페이지에 새로운 Vue 애플리케이션을 추가할 때는 주의해야 합니다(이는 오래된 또는 새로운 탐색을 포함하지 않습니다). 새로운 애플리케이션을 추가하기 전에 기존 애플리케이션을 확장하여 원하는 기능을 달성하는 것이 절대적으로 불가능한지 확인하세요. 의문이 드는 경우, #frontend
또는 #frontend-maintainers
슬랙 채널에서 아키텍처 조언을 요청하세요.
여전히 새로운 애플리케이션을 추가해야 하는 경우, 기존 애플리케이션과 로컬 상태를 공유해야 합니다.
배우기: 어떤 상태 관리자를 사용해야 하는지 어떻게 알 수 있나요?
Vue 아키텍처
우리가 Vue 아키텍처로 달성하고자 하는 주요 목표는 데이터 흐름이 하나만 있고 데이터 입력도 하나만 있도록 하는 것입니다.
이 목표를 달성하기 위해 Pinia 또는 Apollo Client를 사용합니다.
또한 이 아키텍처에 대해 Vue 문서에서 상태 관리 및 단방향 데이터 흐름에 대해 읽어볼 수 있습니다.
구성 요소 및 저장소
Vue.js로 구현된 일부 기능, 예를 들어 이슈 보드
또는 환경 테이블
에서 명확한 관심사의 분리를 확인할 수 있습니다:
new_feature
├── components
│ └── component.vue
│ └── ...
├── store
│ └── new_feature_store.js
├── index.js
일관성을 위해, 동일한 구조를 따를 것을 추천합니다.
각각을 살펴보겠습니다:
index.js
파일
이 파일은 새로운 기능의 인덱스 파일입니다. 새로운 기능의 루트 Vue 인스턴스는 여기에 있어야 합니다.
스토어와 서비스는 이 파일에서 가져오고 초기화하며, 메인 컴포넌트에 prop으로 제공되어야 합니다.
페이지 특정 JavaScript에 대해 읽어보세요.
부트스트랩 시 주의사항
HAML에서 JavaScript로 데이터 제공
Vue 애플리케이션을 마운트하는 동안 Rails에서 JavaScript로 데이터를 제공해야 할 수 있습니다.
이를 위해 HTML 요소의 data
속성을 사용하고 애플리케이션을 마운트할 때 쿼리할 수 있습니다.
이 작업은 애플리케이션을 초기화할 때만 수행해야 하며, 마운트된 요소는 Vue로 생성된 DOM으로 대체됩니다.
data
속성은 오직 문자열 값만 허용합니다,
따라서 다른 변수 유형을 문자열로 변환하거나 캐스팅해야 합니다.
DOM에서 Vue 인스턴스에 props
또는 render
함수의 provide
를 통해 데이터를 제공하는 장점은
메인 Vue 컴포넌트 내부에서 DOM을 쿼리하는 것을 피할 수 있으므로 단위 테스트에서 픽스처 또는 HTML 요소를 만들 필요가 없습니다.
initSimpleApp
헬퍼
initSimpleApp
은 Vue.js에서 컴포넌트를 마운트하는 프로세스를 간소화하는 헬퍼 함수입니다.
HTML에서 마운트 포인트를 나타내는 선택기 문자열과 Vue 컴포넌트를 인수로 받습니다.
initSimpleApp
을 사용하려면:
- 페이지에 ID 또는 고유 클래스가 포함된 HTML 요소를 추가합니다.
- JSON 객체를 포함하는 data-view-model 속성을 추가합니다.
- 원하는 Vue 컴포넌트를 가져와서, 유효한 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)
provide
및 inject
Vue는 provide
와 inject
를 통한 의존성 주입을 지원합니다.
컴포넌트에서 inject
구성은 provide
가 전달하는 값을 접근합니다.
다음은 HAML에서 컴포넌트로 값을 전달하는 provide
구성의 예를 보여주는 Vue 앱 초기화 코드입니다:
#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-drilling이 불편해질 때. prop-drilling은 같은 prop이 계층의 모든 컴포넌트를 통과하여 실제로 이를 사용하는 컴포넌트에 도달하는 과정입니다.
의존성 주입은 다음 두 조건이 모두 참일 경우 자식 컴포넌트(즉각적인 자식 또는 여러 수준 아래)가 깨질 가능성이 있습니다:
-
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로 폼을 구성할 때, 폼 입력의 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
객체 접근하기
우리는 애플리케이션 생명 주기 동안 변경되지 않는 데이터를 위해 gl
객체를 DOM을 쿼리하는 동일한 위치에서 쿼리합니다. 이 관행을 따르면, gl
객체를 모킹할 필요가 없어져 테스트가 더 쉬워집니다. 이는 Vue 인스턴스를 초기화하면서 수행되어야 하며, 데이터는 주요 컴포넌트에 props
로 제공되어야 합니다:
return new Vue({
el: '.js-vue-app',
name: 'MyComponentRoot',
render(createElement) {
return createElement('my-component', {
props: {
avatarUrl: gl.avatarUrl,
},
});
},
});
능력 접근하기
프론트엔드에 능력을 푸시한 후, Vue에서 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) {
// ...
}
},
}
기능 플래그 접근하기
프론트엔드에 기능 플래그를 푸시한 후, Vue에서 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
에 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: '리소스가 백그라운드에서 빌드되고 있습니다.',
variant: 'info',
persistOnPages: ['dashboard:groups:index'],
},
])
지속적으로 유지된 알림을 수동으로 제거해야 하는 경우, removeGlobalAlertById
유틸을 사용할 수 있습니다.
컴포넌트를 위한 폴더
이 폴더는 이 새로운 기능에만 해당되는 모든 컴포넌트를 보관합니다.
어딘가에서 사용될 가능성이 있는 컴포넌트를 사용하거나 생성하려면
vue_shared/components
를 참조하세요.
컴포넌트를 생성해야 할 때 아는 좋은 가이드는 다른 곳에서 재사용 가능할지를 생각하는 것입니다.
예를 들어, 테이블은 GitLab의 여러 장소에서 사용되므로, 테이블은 컴포넌트로 적합할 것입니다. 반면에, 한 테이블에서만 사용되는 테이블 셀은 이 패턴을 사용하는 데에 적합하지 않습니다.
Vue.js 사이트에서 컴포넌트에 대해 더 읽어보세요, 컴포넌트 시스템.
Pinia
Vuex
Vuex는 더 이상 사용되지 않습니다, 마이그레이션을 고려하세요.
Vue Router
페이지에 Vue Router를 추가하려면:
-
와일드카드 이름인
*vueroute
를 사용하여 Rails 라우트 파일에 catch-all 라우트를 추가하세요:# ee/config/routes/project.rb의 예시 resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index
위의 예시는
path
의 시작과 일치하는 모든 경로에 대해iteration_cadences
컨트롤러에서index
페이지를 제공합니다. 예를 들어groupname/projectname/-/cadences/123/456/
과 같습니다. -
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, }, ];
-
-
선택 사항. 자식 경로의 경로 헬퍼도 사용 가능하게 하려면, 부모 컨트롤러를 사용하기 위해
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
와 같은 라우트를 백엔드에서 검증할 수 있게 해줍니다. 예를 들어 그룹 또는 프로젝트 멤버십을 확인하는 것입니다.또한
_path
헬퍼를 사용할 수 있어, 경로의*vueroute
부분을 수동으로 작성하지 않고 기능 사양에서 페이지를 로드할 수 있습니다.
Vue와 jQuery 혼합하기
- Vue와 jQuery 혼합은 권장되지 않습니다.
- Vue에서 특정 jQuery 플러그인을 사용하려면, 주위에 래퍼를 만드세요.
- Vue가 기존 jQuery 이벤트를 jQuery 이벤트 리스너를 사용하여 수신하는 것은 허용됩니다.
- Vue가 jQuery와 상호작용할 수 있도록 새로운 jQuery 이벤트를 추가하는 것은 권장되지 않습니다.
Vue와 JavaScript 클래스 혼합하기 (데이터 함수 내에서)
Vue 문서에서 데이터 함수/객체는 다음과 같이 정의됩니다:
Vue 인스턴스를 위한 데이터 객체. Vue는 그 속성을 재귀적으로 getter/setter로 변환하여 “반응형”으로 만듭니다. 객체는 단순해야 합니다: 브라우저 API 객체와 프로토타입 속성과 같은 기본 객체는 무시됩니다. 데이터는 단순한 데이터이어야 하며, 고유한 상태를 가진 객체를 관찰하는 것은 권장되지 않습니다.
Vue의 가이드를 기반으로:
- 데이터 함수에서 JavaScript 클래스를 사용하거나 생성하지 마세요. data function.
- 새로운 JavaScript 클래스 구현을 추가하지 마세요.
- 복잡한 상태 관리는 응집력 있는 분리된 구성 요소 또는 상태 관리자를 사용하여 캡슐화하세요.
- 기존 구현은 이러한 접근 방식을 사용하여 유지하세요.
- 상당한 변경이 있을 때 구성 요소를 순수 객체 모델로 마이그레이션하세요.
- 헬퍼 또는 유틸리티에 비즈니스 로직을 추가하여 구성 요소와 별도로 테스트할 수 있도록 하세요.
이유
대규모 코드베이스에서 JavaScript 클래스가 유지 관리 문제를 일으키는 추가 이유는 다음과 같습니다:
- 클래스가 생성된 후에는 Vue 반응성과 모범 사례를 침해할 수 있는 방식으로 확장될 수 있습니다.
- 클래스는 추상화의 레이어를 추가하여 구성 요소 API와 그 내부 작업을 덜 명확하게 만듭니다.
- 테스트하는 것이 더 어려워집니다. 클래스가 구성 요소 데이터 함수에 의해 인스턴스화되므로 구성 요소와 클래스를 별도로 관리하기가 더 어려워집니다.
- 기능적 코드베이스에 객체 지향 원칙(OOP)을 추가하면 코드 작성 방법이 하나 더 생겨 일관성과 명확성이 줄어듭니다.
스타일 가이드
Vue 컴포넌트와 템플릿을 작성하고 테스트할 때의 모범 사례는 스타일 가이드의 Vue 섹션을 참조하세요.
컴포지션 API
Vue 2.7에서는 Vue 컴포넌트와 독립형 컴포저블에서 Composition API를 사용할 수 있습니다.
<script>
를 <script setup>
보다 선호하세요
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
에는 이벤트 리스너가 포함되어 있어 Vue 3 마이그레이션이 완료된 후 예기치 않은 부작용을 일으킬 수 있습니다.
구성 요소당 하나의 API 스타일을 목표로 하세요
Vue 구성 요소에 setup()
속성을 추가할 때, 완전히 Composition API로 리팩토링하는 것을 고려하세요. 이는 항상 가능하지는 않지만, 가독성과 유지보수를 위해 구성 요소당 하나의 API 스타일을 목표로 해야 합니다.
조합 가능성
Composition API를 사용하면 반응형 상태를 포함한 로직을 _composables_로 추상화하는 새로운 방법이 생깁니다. 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에서 composables에 대한 일반적인 네이밍 규칙은 use
로 접두사를 붙이고 then composable 기능을 간략하게 언급하는 것입니다(useBreakpoints
, useGeolocation
등). 동일한 규칙이 composables를 포함하고 있는 .js
파일에도 적용됩니다. 파일이 하나 이상의 composable을 포함하고 있더라도 use_
로 시작해야 합니다.
생명 주기 함정 피하기
composable을 만들 때 가능한 한 단순하게 유지하는 것을 목표로 해야 합니다. 생명 주기 훅은 composables에 복잡성을 추가하고 예기치 않은 부작용을 초래할 수 있습니다. 이를 피하기 위해 다음 원칙을 따라야 합니다:
- 가능한 한 생명 주기 훅 사용을 최소화하고, 대신 콜백을 수락/반환하는 것을 선호합니다.
- composable이 생명 주기 훅이 필요하다면, 청소 작업도 수행되도록 해야 합니다.
onMounted
에서 리스너를 추가한다면, 동일한 composable 내의onUnmounted
에서 제거해야 합니다. - 생명 주기 훅은 항상 즉시 설정합니다:
// 나쁨
const useAsyncLogic = () => {
const action = async () => {
await doSomething();
onMounted(doSomethingElse);
};
return { action };
};
// 괜찮음
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을 작성하는 것은 유혹이 될 수 있지만, 대부분의 경우 너무 복잡하고 유지보수가 어려워집니다. 하나의 탈출구는 getCurrentInstance
메서드입니다. 이 메서드는 현재 렌더링되는 구성 요소의 인스턴스를 반환합니다. 이 메서드를 사용하는 대신, 데이터를 composable에 매개변수로 전달하는 것을 선호해야 합니다.
const useSomeLogic = () => {
doSomeLogic();
getCurrentInstance().emit('done'); // 나쁨
};
const done = () => emit('done');
const useSomeLogic = (done) => {
doSomeLogic();
done(); // 좋음, composable이 너무 똑똑해지려고 하지 않음
}
테스트 컴포저블
Vue 컴포넌트 테스트
Vue 컴포넌트 테스트에 대한 지침 및 모범 사례는 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(() => {
// IMPORTANT: 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(() => {
// IMPORTANT: axios 모의 어댑터를 정리하세요
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('todos가 로드되었을 때', () => {
beforeEach(() => {
createWrapper();
// IMPORTANT: 이 컴포넌트는 마운트 시 비동기적으로 데이터를 가져오므로 Vue 템플릿이 업데이트될 때까지 기다립니다.
return wrapper.vm.$nextTick();
});
it('로딩을 표시하지 않아야 합니다', () => {
expect(findLoader().exists()).toBe(false);
});
it('todos를 렌더링해야 합니다', () => {
expect(findTodoData()).toEqual(TEST_TODOS);
});
it('todo가 추가될 때, 새로운 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를 테스트합니다(특히 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('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
를 사용할 때 Vue3 모드에서 실패할 수 있습니다.
const findButton = () => wrapper.findComponent(GlButton);
// 나쁨
findButton().trigger('click');
// 좋음
findButton().vm.$emit('click');
Vue.js 전문가 역할
자신의 머지 요청 및 리뷰가 다음을 보여줄 때만 Vue.js 전문가에 지원해야 합니다:
- Vue 반응성에 대한 깊은 이해
- Vue 및 Pinia 코드는 공식 및 당사 지침에 따라 구조화되어야 합니다
- Vue 구성요소와 Pinia 저장소 테스트에 대한 완전한 이해
- 기존 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"
>추가</gl-button>
</footer>
</template>
</div>
</template>