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을 쿼리하는 대신 propsrender 함수 내에서 DOM에서 props로 데이터를 제공하는 것은 단위 테스트에서 픽처나 HTML 요소를 만드는 것을 피할 수 있습니다.

initSimpleApp 도우미

initSimpleApp은 Vue.js에서 컴포넌트를 마운트하는 프로세스를 간소화하는 도우미 함수입니다. 셀렉터 문자열(HTML에서 마운트 지점을 나타냄)과 Vue 컴포넌트를 나타내는 두 가지 매개변수를 사용합니다.

initSimpleApp 사용 방법:

  1. 페이지에 ID나 고유한 클래스를 갖는 HTML 요소를 포함합니다.
  2. JSON 객체를 포함하는 data-view-model 속성을 추가합니다.
  3. 원하는 Vue 컴포넌트를 가져와 유효한 CSS 선택기 문자열과 함께 initSimpleApp에 전달합니다. 이 문자열은 특정 위치에서 컴포넌트를 마운트합니다.

initSimpleAppdata-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)
provideinject

Vue는 provideinject을 통해 의존성 주입을 지원합니다. 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
      },
    });
  },
});
note
Vue 애플리케이션을 마운트할 때 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,
      },
    });
  },
});

능력에 접근하기

능력을 프론트엔드에 푸시한 이후에는 provideinject 메커니즘을 사용하여 Vue 응용 프로그램의 하위 컴포넌트에서 능력을 사용할 수 있게 합니다. glAbilties 객체는 이미 commons/vue.js에서 제공되므로 플래그를 사용하려면 믹스인만 필요합니다:

// 임의의 하위 컴포넌트

import glAbilitiesMixin from '~/vue_shared/mixins/gl_abilities_mixin';

export default {
  // ...
  mixins: [glAbilitiesMixin()],
  // ...
  created() {
    if (this.glAbilities.someAbility) {
      // ...
    }
  },
}

피처 플래그에 접근하기

피처 플래그를 프론트엔드에 푸시한 이후에는 provideinject 메커니즘을 사용하여 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를 추가하려면:

  1. 와일드카드인 *vueroute를 사용하여 Rails 라우트 파일에 캐치 올 라우트를 추가하세요.

    # ee/config/routes/project.rb에서의 예시
       
    resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index
    

    위의 예시는 iteration_cadences 컨트롤러의 index 페이지를 path의 시작 부분과 일치하는 모든 라우트에 제공합니다. 예를 들어 groupname/projectname/-/cadences/123/456/와 같은 라우트입니다.

  2. 프론트엔드에서 기본 라우트(최종 *vueroute 이전의 모든 부분)를 Vue Router를 초기화할 때 base 매개변수로 사용하기 위해 전달하세요.

    .js-my-app{ data: { base_path: project_iteration_cadences_path(project) } }
    
  3. 라우터를 초기화하세요.

    Vue.use(VueRouter);
       
    export function createRouter(basePath) {
      return new VueRouter({
        routes: createRoutes(),
        mode: 'history',
        base: basePath,
      });
    }
    
  4. path: '*'로 인식되지 않은 라우트에 대한 대체 방법을 추가하세요. 다음 중 하나:
    • 라우트 배열 끝에 리디렉션을 추가하세요.

      const routes = [
        {
          path: '/',
          name: 'list-page',
          component: ListPage,
        },
        {
          path: '*',
          redirect: '/',
        },
      ];
      
    • 라우트 배열 끝에 대체 컴포넌트를 추가하세요.

      const routes = [
        {
          path: '/',
          name: 'list-page',
          component: ListPage,
        },
        {
          path: '*',
          component: NotFound,
        },
      ];
      
  5. 선택 사항. 자식 라우트에 대한 path 도우미를 사용하려면 부모 컨트롤러를 사용하려면 controlleraction 매개변수를 추가하세요.

    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" 사용을 피하십시오.

  1. 컴포넌트의 계약 손실. props는 특별히 이 문제를 해결하도록 설계되었습니다.
  2. 트리 내의 각 컴포넌트에 대한 높은 유지 관리 비용. v-bind="$attrs"는 데이터 흐름을 이해하기 위해 컴포넌트 트리 전체를 스캔해야 하기 때문에 특히 디버깅하기 어렵습니다.
  3. 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 }]);
    });
  });
});

자식 컴포넌트

  1. 자식 컴포넌트가 렌더링되는 방식을 정의하는 지시문(예: v-ifv-for)을 테스트합니다.
  2. 자식 컴포넌트에 전달하는 props를 테스트합니다(특히 테스트 중인 컴포넌트에서 계산되는 prop의 경우, 예: computed 속성을 사용하여). .props()를 사용하고 .vm.someProp을 사용하지 않도록 주의하세요.
  3. 자식 컴포넌트에서 발생한 이벤트에 올바르게 반응하는지 확인합니다:
  const checkbox = wrapper.findByTestId('checkboxTestId');
  
  expect(checkbox.attributes('disabled')).not.toBeDefined();
  
  findChildComponent().vm.$emit('primary');
  await nextTick();
  
  expect(checkbox.attributes('disabled')).toBeDefined();
  1. 절대로 자식 컴포넌트의 내부 구현을 테스트하지 않습니다:
  // 나쁨
  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>