Vue

Vue를 시작하려면 이 문서를 읽어보세요.

예시

다음 섹션에서 설명하는 내용은 다음 예시에서 찾을 수 있습니다:

Vue 어플리케이션을 추가해야 하는 경우

가끔, HAML 페이지만으로 요구 조건을 충족하는 데 충분합니다. 이 설명은 주로 정적 페이지나 로직이 매우 적은 페이지에 대해서 옳습니다. 페이지에 Vue 어플리케이션을 추가할 가치가 있는지 어떻게 알 수 있을까요? 답은 “어플리케이션 상태를 유지하고 렌더링된 페이지와 동기화해야 하는 경우”입니다.

이를 더 잘 설명하기 위해, 토글이 하나 있는 페이지를 상상해보죠. 그 토글을 토글하면 API 요청을 보냅니다. 이 경우 유지하고 싶은 상태는 없으며, 요청을 보내고 토글을 전환합니다. 그러나, 첫 번째 토글과 항상 반대여아 하는 하나의 추가 토글을 추가한다면, 우리는 상태 가 필요합니다. 하나의 토글은 다른 토글의 상태를 “알아야”합니다. 순수 자바스크립트로 작성할 때, 이 로직은 보통 DOM 이벤트를 수신하고 DOM 수정으로 반응하는 것을 포함합니다. 이와 같은 경우는 Vue.js로 훨씬 더 쉽게 처리할 수 있기 때문에 여기에 Vue 어플리케이션을 만들어야 합니다.

Vue 어플리케이션이 필요할지도 모르는 신호는 무엇인가요?

  • 여러 요인을 기반으로 복잡한 조건문을 정의하고 사용자 상호작용에 따라 이를 업데이트해야 할 때;
  • 어떠한 형태의 어플리케이션 상태를 유지하고 태그/요소간에 공유해야 할 때;
  • 미래에 복잡한 로직을 추가할 것으로 예상될 때 - 기본적인 Vue 어플리케이션부터 시작하는 것이 JS/HAML을 다음 단계에서 Vue로 다시 작성해야 하는 것보다 쉽습니다.

페이지에 여러 Vue 어플리케이션 추가 피하기

과거에, 페이지에 상호작용성을 점진적으로 추가하여 렌더링된 HAML 페이지의 여러 부분에 여러 작은 Vue 어플리케이션을 추가했습니다. 그러나, 이 방식은 다음과 같은 여러 복잡성으로 이어졌습니다:

  • 대부분의 경우, 이러한 어플리케이션들은 상태를 공유하지 않으며 API 요청을 독립적으로 수행하고 이는 요청의 수를 증가시킵니다;
  • 여러 엔드포인트를 통해 레일즈에서 Vue로 데이터를 제공해야 합니다;
  • 페이지 로드 후에 동적으로 Vue 어플리케이션을 렌더링할 수 없기 때문에 페이지 구조가 유연해지지 않습니다;
  • 레일즈 라우팅을 대체하기 위해 클라이언트 측 라우팅을 완전히 활용할 수 없습니다;
  • 여러 어플리케이션은 예측할 수 없는 사용자 경험, 증가된 페이지 복잡성, 더 어려운 디버깅 프로세스로 이어집니다;
  • 앱간의 통신 방식은 웹 핵심 지표 숫자에 영향을 줍니다.

이러한 이유로, 이미 존재하는 Vue 어플리케이션이 있는 페이지에 새로운 Vue 어플리케이션을 추가하는 데 조심해야 합니다 (이전 또는 새로운 네비게이션은 이에 포함되지 않습니다). 새로운 어플리케이션을 추가하기 전에, 원하는 기능을 달성하기 위해 기존 어플리케이션을 확장할 수 없는 경우에만 추가해야 합니다. 의문이 들 경우 #frontend 또는 #frontend-maintainers Slack 채널에서 구조적인 조언을 구할 수 있습니다.

새 어플리케이션을 추가해야 하는 경우, 기존 어플리케이션과 로컬 상태를 공유하도록 보장하십시오 (가능하면 Apollo Client 또는 REST API를 사용하는 경우 Vuex를 사용).

Vue 아키텍처

Vue 아키텍처를 통해 달성하려는 주요 목표는 하나의 데이터 흐름만 있고 하나의 데이터 입력만 있어야 합니다. 이 목표를 달성하기 위해 Vuex 또는 Apollo Client를 사용합니다.

Vue 문서의 상태 관리단방향 데이터 흐름에 대해 읽어보세요.

컴포넌트 및 스토어

Vue.js로 구현된 이슈 보드나 환경 테이블과 같은 일부 기능에서 관심사를 명확히 분리하는 것을 찾을 수 있습니다:

new_feature
├── components
│   └── component.vue
│   └── ...
├── store
│  └── new_feature_store.js
├── index.js

일관성을 유지하기 위해 동일한 구조를 따르는 것을 권장합니다.

이러한 각 항목을 살펴보겠습니다.

index.js 파일

이 파일은 새로운 기능의 인덱스 파일입니다. 새로운 기능의 루트 Vue 인스턴스가 여기에 있어야 합니다.

Store 및 Service는 이 파일에서 가져오고 초기화되어 메인 컴포넌트에 prop으로 제공되어야 합니다.

페이지별 자바스크립트에 대해 읽어보십시오.

부트스트래핑 Gotchas

HAML에서 JavaScript로 데이터 제공

Vue 애플리케이션을 마운트하는 동안 Rails에서 JavaScript로 데이터를 제공해야 할 수 있습니다. 이를 위해 HTML 요소의 data 속성을 사용하여 애플리케이션을 마운트하는 동안에만 실행해야 합니다. 마운트된 요소는 Vue에서 생성된 DOM으로 교체되기 때문에 이 작업은 초기화할 때만 수행해야 합니다.

data 속성은 문자열 값만 허용되므로 다른 변수 유형을 문자열로 캐스트하거나 변환해야 합니다.

주요 Vue 컴포넌트 내부에서 DOM을 쿼리하는 대신 propsrender 함수의 provide를 통해 DOM에서 Vue 인스턴스로 데이터를 제공하는 장점은 단위 테스트에서 fixture나 HTML 요소를 생성하는 것을 피할 수 있다는 점입니다.

initSimpleApp 도우미

initSimpleApp은 Vue.js에서 컴포넌트를 마운트하는 프로세스를 간소화하는 도우미 함수입니다. 이 함수는 HTML의 마운트 지점을 나타내는 선택기 문자열과 Vue 컴포넌트 두 가지 인수를 받습니다.

initSimpleApp 사용 방법:

  1. 페이지에 ID나 고유한 클래스가 있는 HTML 요소를 포함합니다.
  2. JSON 객체를 포함하는 data-view-model 속성을 가진 HTML 요소를 추가합니다.
  3. 원하는 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": "내 객체", "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 앱 초기화 예제는 HAML에서 컴포넌트로 값을 전달하는 provide 구성을 보여줍니다:

#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-drilling이 불편해지는 경우

의존성 주입은 잠재적으로 자식 컴포넌트(직접적인 자식 또는 여러 수준의 하위)를 손상시킬 수 있습니다. 양 조건이 모두 참인 경우:

  • 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
      },
    });
  },
});

참고: Vue 애플리케이션을 마운트할 때 id 속성을 추가할 때 해당 id가 코드베이스 전체에서 고유한지 확인하세요.

Vue 앱으로 전달되는 데이터를 명시적으로 선언하는 이유에 대한 자세한 정보는 Vue 스타일 가이드를 참조하세요.

Rails 양식 필드 제공을 Vue 애플리케이션으로

Rails 양식을 작성할 때 양식 입력의 name, id, value 속성은 백엔드와 일치하도록 생성됩니다. Rails 양식을 Vue로 변환하거나 구성 요소를 통합할 때 이러한 생성된 속성에 액세스할 수 있는 것은 도움이 될 수 있습니다(예: 날짜 선택기 또는 프로젝트 선택기). 생성된 양식 입력 속성을 구문 분석하여 Vue 애플리케이션에 전달할 수 있도록 하는 parseRailsFormFields 유틸리티를 사용할 수 있습니다. 이를 통해 양식이 제출되는 방식을 변경하지 않고 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,
      },
    });
  },
});

능력에 액세스하기

frontend에 능력을 푸시한 후 Vue에서 provideinject 메커니즘을 사용하여 Vue 애플리케이션의 어떠한 하위 구성 요소에서도 능력을 사용할 수 있도록 할 수 있습니다. glAbilties 객체는 이미 commons/vue.js에 제공되므로 플래그를 사용하려면 mixin만 필요합니다:

// 임의 하위 구성 요소

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

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

기능 플래그에 액세스하기

frontend에 기능 플래그를 푸시한 후 Vue에서 provideinject 메커니즘을 사용하여 Vue 애플리케이션의 어떠한 하위 구성 요소에서도 기능 플래그를 사용할 수 있도록 할 수 있습니다. glFeatures 객체는 이미 commons/vue.js에 제공되므로 플래그를 사용하려면 mixin만 필요합니다:

// 임의 하위 구성 요소

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로 테스트하기 좋습니다. 플래그를 props로 제공할 수 있습니다.

    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 유틸을 사용할 수 있습니다.

컴포넌트용 폴더

이 폴더는 이 새로운 기능에 특화된 모든 컴포넌트를 보관합니다. 다른 곳에서 사용될 가능성이 높은 컴포넌트를 사용하거나 생성하기 위해 vue_shared/components를 참조하세요.

컴포넌트를 생성해야 하는 시기를 알기 위한 좋은 지침은 다른 곳에서 재사용될 수 있는지 여부를 생각하는 것입니다.

예를 들어, 테이블은 GitLab 전반적으로 꽤 많은 곳에서 사용되므로 테이블은 컴포넌트에 적합합니다. 반면에 한 테이블에서만 사용되는 테이블 셀은 이 패턴의 좋은 사용 사례가 아닙니다.

Vue.js 사이트에서 컴포넌트에 대해 더 읽어보세요. 컴포넌트 시스템.

스토어용 폴더

Vuex

더 많은 세부 정보는 이 페이지에서 확인하세요.

Vue Router

페이지에 Vue Router를 추가하려면:

  1. 와일드카드인 *vueroute라는 이름으로 레일즈 라우트 파일에 캐치-올(route) 를 추가합니다:

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

    위 예시는 iteration_cadences 컨트롤러의 index 페이지를 일치하는 경로의 시작점에 대해 제공합니다. 예를 들어 groupname/projectname/-/cadences/123/456/와 같은 경로에 대해 할당됩니다.

  2. base 를 Vue Router를 초기화하는 데 사용하기 위해 프론트엔드에 전달하세요:

    .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: '*'로 인식되지 않는 경로를 위한 후폴백(fallback)을 추가합니다. 다음 중 하나를 수행하세요:
    • 라우트 배열 끝에 리디렉션을 추가합니다:

      const routes = [
        {
          path: '/',
          name: 'list-page',
          component: ListPage,
        },
        {
          path: '*',
          redirect: '/',
        },
      ];
      
    • 라우트 배열 마지막에 후폴백(fallback) 컴포넌트를 추가합니다:

      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
    end
    

    이는 예를 들어 백엔드에서 그룹 또는 프로젝트 멤버십을 확인하기 위해 *vueroute 경로의 일부를 수동으로 빌드하지 않고도 백엔드에서 /cadences/123/iterations/456/edit와 같은 경로를 확인할 수 있도록 합니다. 또한 부모 컨트롤러를 사용하여 _path 도우미를 사용할 수 있게 됩니다.

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 섹션을 참조하십시오. 스타일 가이드.

구성 API

Vue 2.7부터 Vue 컴포넌트와 독립형 컴포저블에서 구성 API를 사용할 수 있습니다.

<script setup> 대신 <script> 사용을 선호하세요

구성 API를 사용하면 <script> 섹션에 로직을 배치하거나 전용 <script setup> 섹션을 추가할 수 있습니다. 우리는 <script>를 사용하고 setup() 속성을 사용하여 컴포넌트에 구성 API를 추가해야 합니다:

<script>
  import { computed } from 'vue';

  export default {
    name: 'MyComponent',
    setup(props) {
      const doubleCount = computed(() => props.count*2)
    }
  }
</script>

v-bind 제한 사항

네이티브 컨트롤 래퍼를 개발할 때를 제외하고는 v-bind="$attrs" 사용을 피하십시오.

v-bind="$attrs"을 사용하면 다음과 같은 문제가 발생합니다:

  1. 컴포넌트의 계약이 손상됩니다. props는 특히 이 문제를 해결하기 위해 설계되었습니다.
  2. 트리 내 각 컴포넌트의 유지 관리 비용이 높아집니다. v-bind="$attrs"는 데이터 흐름 이해를 위해 컴포넌트의 전체 계층 구조를 검사해야 하므로 특히 디버깅이 어렵습니다.
  3. Vue 3로 마이그레이션 중 문제가 발생합니다. Vue 3의 $attrs에는 예기치 않은 부작용을 일으킬 수 있는 이벤트 리스너가 포함되어 있습니다.

컴포넌트 당 하나의 API 스타일을 목표로

setup() 속성을 Vue 컴포넌트에 추가할 때, 가독성과 유지 관리를 위해 컴포넌트를 구성 API로 전체적으로 리팩터링해야 합니다. 특히 대규모 컴포넌트의 경우에는 항상 가능하지는 않지만 컴포넌트 당 하나의 API 스타일을 목표로 해야 합니다.

컴포저블

구성 API로 인해 반응적 상태를 포함한 로직을 추상화하는 새로운 방법인 _컴포저블_이 등장했습니다. 컴포저블은 매개변수를 수락하고 반응하는 속성과 메서드를 반환하는 함수입니다.

// 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를 접두어로 붙이고 간단히 컴포저블 기능을 참조하는 것입니다(useBreakpoints, useGeolocation 등). 이 규칙은 컴포저블을 포함하는 .js 파일에도 적용됩니다. 파일에 하나 이상의 컴포저블이 포함되어 있더라도 use_로 시작해야 합니다.

라이프사이클 피트폴을 피하기

컴포저블을 작성할 때 가능한 한 간단하게 유지하는 것이 좋습니다. 라이프사이클 후크는 복잡성을 더하고 예기치 않은 부작용을 일으킬 수 있습니다. 이를 피하기 위해 다음 원칙을 따라야 합니다:

  • 가능한 경우 라이프사이클 후크 사용을 최소화하고 대신 콜백을 받아오거나 반환하는 것을 선호합니다.
  • 컴포저블에서 라이프사이클 후크가 필요한 경우, 해당 후크에서 청소를 수행해야 합니다. onMounted에서 청취자를 추가했다면, 동일한 컴포저블 내에서 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 };
};

탈출 비행구 피하기

모든 것을 블랙 박스로 하는 컴포저블을 작성하는 것이 유혹적일 수 있습니다. 이 때 Vue가 제공하는 탈출 비행구 중 일부를 사용하는 것이 그것입니다. 그러나 대부분의 경우에 이것은 너무 복잡하고 유지보수하기 힘들게 만듭니다. getCurrentInstance 메서드는 하나의 탈출 비행구입니다. 이 메서드는 현재 렌더링 컴포넌트의 인스턴스를 반환합니다. 이 메서드 대신에 컴포저블에게 데이터 또는 메서드를 인수를 통해 전달하는 것이 좋습니다.

const useSomeLogic = () => {
  doSomeLogic();
  getCurrentInstance().emit('done'); // 나쁨
};
const done = () => emit('done');

const useSomeLogic = (done) => {
  doSomeLogic();
  done(); // 좋음, 컴포저블이 지나치게 스마트하게 되려고 하지 않음
}

컴포저블과 Vuex

컴포저블에서는 Vuex 상태 사용을 최대한 피해야 합니다. 그렇게 할 수 없는 경우, 해당 상태를 받아오기 위해 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 컴포넌트의 경우 고유한 출력이 있습니다. 이 출력은 항상 렌더 함수에 존재합니다.

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;

// 중략

하위 구성 요소

  1. 하위 컴포넌트를 렌더링하는 방법을 정의하는 어떤 디렉티브를 테스트하세요 (예: v-ifv-for).
  2. 테스트 중인 컴포넌트 내에서 계산된 속성인 경우 특히 computed 속성으로 계산된 속성에 주의하여 하위 컴포넌트에 전달하는 프롭을 테스트하세요. .vm.someProp이 아닌 .props()를 사용하는 것을 기억하세요.
  3. 하위 컴포넌트에서 발생한 이벤트에 올바르게 반응하는지 테스트하세요.
  const checkbox = wrapper.findByTestId('checkboxTestId');

  expect(checkbox.attributes('disabled')).not.toBeDefined();

  findChildComponent().vm.$emit('primary');
  await nextTick();

  expect(checkbox.attributes('disabled')).toBeDefined();
  1. 하위 컴포넌트의 내부 구현을 테스트하지 마십시오:
  // bad
  expect(findChildComponent().find('.error-alert').exists()).toBe(false);

  // good
  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를 사용하는 것은 해당 컴포넌트를 화이트 박스로 취급한다는 것을 의미합니다. 즉, 하위 컴포넌트의 루트 요소가 네이티브 click 이벤트를 가정한다고 가정하는 것입니다. 또한 trigger를 사용하면 Vue3 모드에서 하위 컴포넌트에 trigger를 사용할 때 일부 테스트가 실패합니다.

   const findButton = () => wrapper.findComponent(GlButton);

   // bad
   findButton().trigger('click');

   // good
   findButton().vm.$emit('click');

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>