InfoGrab DocsInfoGrab Docs

Vue

요약

Vue를 시작하려면 Vue 공식 문서를 읽어보세요. 다음 섹션에서 설명하는 내용은 아래 예시에서 확인할 수 있습니다: 경우에 따라 HAML 페이지만으로도 요구사항을 충족할 수 있습니다. 이를 더 잘 설명하기 위해, 하나의 토글이 있는 페이지를 상상해 봅시다.

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

예시#

다음 섹션에서 설명하는 내용은 아래 예시에서 확인할 수 있습니다:

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

경우에 따라 HAML 페이지만으로도 요구사항을 충족할 수 있습니다. 이 사실은 주로 정적 페이지나 로직이 거의 없는 페이지에 해당합니다. 페이지에 Vue 애플리케이션을 추가할 가치가 있는지 어떻게 알 수 있을까요? 답은 "애플리케이션 상태를 유지하고 렌더링된 페이지를 상태와 동기화해야 할 때"입니다.

이를 더 잘 설명하기 위해, 하나의 토글이 있는 페이지를 상상해 봅시다. 토글을 누르면 API 요청이 전송됩니다. 이 경우에는 유지하고자 하는 상태가 없으므로 요청을 보내고 토글을 전환하기만 하면 됩니다. 그러나 항상 첫 번째 토글과 반대여야 하는 토글을 하나 더 추가한다면, 상태가 필요해집니다. 즉, 하나의 토글이 다른 토글의 상태를 "인식"해야 합니다. 순수 JavaScript로 작성하면 이 로직은 보통 DOM 이벤트를 수신하고 DOM을 수정하는 방식으로 처리됩니다. 이런 경우는 Vue.js로 훨씬 쉽게 처리할 수 있으므로 여기서 Vue 애플리케이션을 만들어야 합니다.

페이지에 Vue 애플리케이션 추가하는 방법#

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 helper#

initSimpleApp은 Vue.js에서 컴포넌트를 마운트하는 과정을 간소화하는 헬퍼 함수입니다. HTML의 마운트 포인트를 나타내는 선택자 문자열과 Vue 컴포넌트, 두 가지 인수를 받습니다.

initSimpleApp 사용 방법:

  • ID 또는 고유한 클래스가 있는 HTML 요소를 페이지에 포함합니다.

  • JSON 객체를 담은 data-view-model 속성을 추가합니다.

  • 원하는 Vue 컴포넌트를 가져온 다음, initSimpleApp에 HTML 요소를 선택하는 유효한 CSS 선택자 문자열과 함께 전달합니다. 이 문자열은 지정된 위치에 컴포넌트를 마운트합니다.

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 컴포넌트를 가져온 다음, initSimpleApp에 HTML 요소를 선택하는 유효한 CSS 선택자 문자열과 함께 전달합니다. 이 문자열은 지정된 위치에 컴포넌트를 마운트합니다.

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는 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에서 값을 제공하기 위해 의존성 주입을 사용하는 것이 이상적인 경우:

  • 주입된 값이 데이터 타입이나 내용에 대한 명시적인 유효성 검사가 필요하지 않을 때.

  • 값이 반응형일 필요가 없을 때.

  • 이 값에 접근해야 하는 컴포넌트가 계층 구조에 여러 개 있어 prop 드릴링이 불편한 경우. Prop 드릴링은 실제로 사용하는 컴포넌트에 도달할 때까지 계층 구조의 모든 컴포넌트를 통해 동일한 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
      },
    });
  },
});

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에서 provideinject 메커니즘을 사용하여 Vue 애플리케이션의 모든 하위 컴포넌트에서 능력을 사용할 수 있도록 합니다. glAbilties 객체는 commons/vue.js에서 이미 제공되므로, 플래그를 사용하기 위해 믹스인(mixin)만 있으면 됩니다:

// An arbitrary descendant component

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

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

기능 플래그 접근#

프론트엔드에 기능 플래그를 푸시한 후, Vue에서 provideinject 메커니즘을 사용하여 Vue 애플리케이션의 모든 하위 컴포넌트에서 기능 플래그를 사용할 수 있도록 합니다. glFeatures 객체는 commons/vue.js에서 이미 제공되므로, 플래그를 사용하기 위해 믹스인(mixin)만 있으면 됩니다:

// 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-utilsmount/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#

GitLab에서 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를 사용할 수 있도록 하려면, 부모 컨트롤러를 사용하기 위해 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

이를 통해 /cadences/123/iterations/456/edit와 같은 라우트를 백엔드에서 검증할 수 있습니다(예: 그룹 또는 프로젝트 멤버십 확인). 또한 _path helper를 사용할 수 있으므로 *vueroute 부분을 수동으로 빌드하지 않아도 기능 스펙에서 페이지를 로드할 수 있습니다.

Vue와 jQuery 혼합 사용#

  • Vue와 jQuery를 혼합하여 사용하는 것은 권장하지 않습니다.

  • Vue에서 특정 jQuery 플러그인을 사용하려면 그 주변에 래퍼를 만드세요.

  • Vue가 jQuery 이벤트 리스너를 사용하여 기존 jQuery 이벤트를 수신하는 것은 허용됩니다.

  • Vue가 jQuery와 상호작용하기 위해 새 jQuery 이벤트를 추가하는 것은 권장하지 않습니다.

Vue와 JavaScript 클래스 혼합 사용 (data 함수에서)#

Vue 문서에서 Data 함수/객체는 다음과 같이 정의됩니다:

Vue 인스턴스의 데이터 객체입니다. Vue는 속성을 재귀적으로 getter/setter로 변환하여 "반응형"으로 만듭니다. 객체는 일반 객체여야 합니다: 브라우저 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에는 이벤트 리스너가 포함되어 있어 Vue 3 마이그레이션이 완료된 후 예상치 못한 부작용이 발생할 수 있습니다.

컴포넌트당 하나의 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-ifv-for)을 정의하는 디렉티브를 테스트합니다.

하위 컴포넌트에 전달하는 props를 테스트합니다(특히 prop이 테스트 대상 컴포넌트에서 computed 속성으로 계산된 경우). .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를 사용할 때 일부 테스트가 Vue3 모드에서 실패합니다.

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>

Vue

GitLab v19.1
원문 보기
요약

Vue를 시작하려면 Vue 공식 문서를 읽어보세요. 다음 섹션에서 설명하는 내용은 아래 예시에서 확인할 수 있습니다: 경우에 따라 HAML 페이지만으로도 요구사항을 충족할 수 있습니다. 이를 더 잘 설명하기 위해, 하나의 토글이 있는 페이지를 상상해 봅시다.

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

예시#

다음 섹션에서 설명하는 내용은 아래 예시에서 확인할 수 있습니다:

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

경우에 따라 HAML 페이지만으로도 요구사항을 충족할 수 있습니다. 이 사실은 주로 정적 페이지나 로직이 거의 없는 페이지에 해당합니다. 페이지에 Vue 애플리케이션을 추가할 가치가 있는지 어떻게 알 수 있을까요? 답은 "애플리케이션 상태를 유지하고 렌더링된 페이지를 상태와 동기화해야 할 때"입니다.

이를 더 잘 설명하기 위해, 하나의 토글이 있는 페이지를 상상해 봅시다. 토글을 누르면 API 요청이 전송됩니다. 이 경우에는 유지하고자 하는 상태가 없으므로 요청을 보내고 토글을 전환하기만 하면 됩니다. 그러나 항상 첫 번째 토글과 반대여야 하는 토글을 하나 더 추가한다면, 상태가 필요해집니다. 즉, 하나의 토글이 다른 토글의 상태를 "인식"해야 합니다. 순수 JavaScript로 작성하면 이 로직은 보통 DOM 이벤트를 수신하고 DOM을 수정하는 방식으로 처리됩니다. 이런 경우는 Vue.js로 훨씬 쉽게 처리할 수 있으므로 여기서 Vue 애플리케이션을 만들어야 합니다.

페이지에 Vue 애플리케이션 추가하는 방법#

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 helper#

initSimpleApp은 Vue.js에서 컴포넌트를 마운트하는 과정을 간소화하는 헬퍼 함수입니다. HTML의 마운트 포인트를 나타내는 선택자 문자열과 Vue 컴포넌트, 두 가지 인수를 받습니다.

initSimpleApp 사용 방법:

  • ID 또는 고유한 클래스가 있는 HTML 요소를 페이지에 포함합니다.

  • JSON 객체를 담은 data-view-model 속성을 추가합니다.

  • 원하는 Vue 컴포넌트를 가져온 다음, initSimpleApp에 HTML 요소를 선택하는 유효한 CSS 선택자 문자열과 함께 전달합니다. 이 문자열은 지정된 위치에 컴포넌트를 마운트합니다.

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 컴포넌트를 가져온 다음, initSimpleApp에 HTML 요소를 선택하는 유효한 CSS 선택자 문자열과 함께 전달합니다. 이 문자열은 지정된 위치에 컴포넌트를 마운트합니다.

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는 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에서 값을 제공하기 위해 의존성 주입을 사용하는 것이 이상적인 경우:

  • 주입된 값이 데이터 타입이나 내용에 대한 명시적인 유효성 검사가 필요하지 않을 때.

  • 값이 반응형일 필요가 없을 때.

  • 이 값에 접근해야 하는 컴포넌트가 계층 구조에 여러 개 있어 prop 드릴링이 불편한 경우. Prop 드릴링은 실제로 사용하는 컴포넌트에 도달할 때까지 계층 구조의 모든 컴포넌트를 통해 동일한 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
      },
    });
  },
});

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에서 provideinject 메커니즘을 사용하여 Vue 애플리케이션의 모든 하위 컴포넌트에서 능력을 사용할 수 있도록 합니다. glAbilties 객체는 commons/vue.js에서 이미 제공되므로, 플래그를 사용하기 위해 믹스인(mixin)만 있으면 됩니다:

// An arbitrary descendant component

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

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

기능 플래그 접근#

프론트엔드에 기능 플래그를 푸시한 후, Vue에서 provideinject 메커니즘을 사용하여 Vue 애플리케이션의 모든 하위 컴포넌트에서 기능 플래그를 사용할 수 있도록 합니다. glFeatures 객체는 commons/vue.js에서 이미 제공되므로, 플래그를 사용하기 위해 믹스인(mixin)만 있으면 됩니다:

// 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-utilsmount/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#

GitLab에서 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를 사용할 수 있도록 하려면, 부모 컨트롤러를 사용하기 위해 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

이를 통해 /cadences/123/iterations/456/edit와 같은 라우트를 백엔드에서 검증할 수 있습니다(예: 그룹 또는 프로젝트 멤버십 확인). 또한 _path helper를 사용할 수 있으므로 *vueroute 부분을 수동으로 빌드하지 않아도 기능 스펙에서 페이지를 로드할 수 있습니다.

Vue와 jQuery 혼합 사용#

  • Vue와 jQuery를 혼합하여 사용하는 것은 권장하지 않습니다.

  • Vue에서 특정 jQuery 플러그인을 사용하려면 그 주변에 래퍼를 만드세요.

  • Vue가 jQuery 이벤트 리스너를 사용하여 기존 jQuery 이벤트를 수신하는 것은 허용됩니다.

  • Vue가 jQuery와 상호작용하기 위해 새 jQuery 이벤트를 추가하는 것은 권장하지 않습니다.

Vue와 JavaScript 클래스 혼합 사용 (data 함수에서)#

Vue 문서에서 Data 함수/객체는 다음과 같이 정의됩니다:

Vue 인스턴스의 데이터 객체입니다. Vue는 속성을 재귀적으로 getter/setter로 변환하여 "반응형"으로 만듭니다. 객체는 일반 객체여야 합니다: 브라우저 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에는 이벤트 리스너가 포함되어 있어 Vue 3 마이그레이션이 완료된 후 예상치 못한 부작용이 발생할 수 있습니다.

컴포넌트당 하나의 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-ifv-for)을 정의하는 디렉티브를 테스트합니다.

하위 컴포넌트에 전달하는 props를 테스트합니다(특히 prop이 테스트 대상 컴포넌트에서 computed 속성으로 계산된 경우). .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를 사용할 때 일부 테스트가 Vue3 모드에서 실패합니다.

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>