Skip to content

컴포넌트 작성 규칙

프로젝트의 Vue 파일은 아래 규칙을 따릅니다. 문법 자체는 Vue3 + TypeScript 를, 여기서는 프로젝트 규약을 다룹니다.

Options API 는 사용하지 않습니다

현재 코드베이스는 전부 Composition API + <script setup> 입니다. export default { data(), methods, mounted() } 형태의 예제를 참고하지 마세요.

파일명

kebab-case 를 쓰고, 복수형을 쓰지 않습니다.

용도파일명
목록board-list.vue
상세board-detail.vue
모달board-detail.modal.vue
❌ boards.vue          복수형
❌ BoardList.vue       PascalCase
✅ board-list.vue

커스텀 컴포넌트 접두사

프로젝트 공용 컴포넌트는 nv- 접두사를 붙입니다.

nv-button, nv-input, nv-datepicker, nv-file-upload, nv-form-group

import 는 대부분 불필요합니다

unplugin-vue-componentssrc/components 아래를 자동 등록합니다. 파일명 그대로 <nv-file-upload /> 를 쓰면 됩니다.

파일 구조

블록 순서를 고정합니다.

vue
<template>
</template>

<script lang="ts" setup>
</script>

<style scoped lang="scss">
</style>

<script setup> 내부 순서

위쪽은 외부와의 연결, 아래쪽은 내부 로직입니다. 구분 주석을 함께 씁니다.

vue
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useQuasar } from 'quasar';
import type { UserInfo } from '@/types';

// ------------------------------------------------------------------------- Props & Models & Emits
const props = defineProps<{
  id: string;
  status?: string;
}>();

const model = defineModel<Table | null>('model', { required: true });

const emit = defineEmits<{
  (e: 'update', value: string): void;
  (e: 'close'): void;
}>();

// ------------------------------------------------------------------------- Hooks & Stores & Composables
const $q = useQuasar();
const userStore = useUserStore();

// ------------------------------------------------------------------------- State (Refs/Reactive)
const loading = ref(false);
const formData = ref<UserInfo>({ name: '', email: '' });

// ------------------------------------------------------------------------- Computed
const isValid = computed(() => formData.value.name.length > 0);

// ------------------------------------------------------------------------- Methods
const handleSubmit = async () => {
  loading.value = true;
  try {
    emit('update', formData.value.name);
  } finally {
    loading.value = false;
  }
};

// ------------------------------------------------------------------------- Lifecycle Hooks
onMounted(() => { /* ... */ });

// ------------------------------------------------------------------------- Watchers
watch(() => props.id, (newId) => { /* ... */ });

// ------------------------------------------------------------------------- Expose
defineExpose({ handleSubmit });
</script>
순서내용
1Imports
2Props & Models & Emits — 컴포넌트의 인터페이스
3Hooks & Stores & Composables
4State (ref / reactive)
5Computed
6Methods
7Lifecycle Hooks
8Watchers
9Expose

필수 패턴

v-modeldefineModel

typescript
const visible = defineModel<boolean>({ required: true });

props + emit + watch 2개로 양방향 동기화하던 방식은 사용 금지입니다 → 상세

템플릿 참조 → useTemplateRef

typescript
const inputRef = useTemplateRef<HTMLInputElement>('inputRef');

ref(null) 방식은 사용 금지입니다 → 상세

computed vs ref

상황선택
다른 반응형 값에서 파생되는 값computed
이벤트로 직접 변경되는 값ref
API 호출 결과를 담는 값ref
한 번 설정하고 안 바뀌는 값ref
typescript
// computed — 파생
const fullName = computed(() => firstName.value + ' ' + lastName.value);
const filteredItems = computed(() => items.value.filter(i => i.active));

// ref — 독립 상태
const selectedItem = ref<Item | null>(null);   // 클릭으로 변경
const isOpen = ref(false);                     // 토글로 변경

API 호출은 서비스 레이어를 거칩니다

컴포넌트에서 api.get(...) 을 직접 호출하지 않습니다.

typescript
// ❌ 컴포넌트에서 직접
const { data } = await api.get('/api/board');

// ✅ 서비스 레이어 경유
import BoardService from 'src/services/board/board.service';

const boardService = new BoardService();
const { data } = await boardService.list(query);

왜 분리하나

URL·파라미터 규약이 한 곳에 모여 있어야 백엔드 계약이 바뀔 때 수정 지점이 하나가 됩니다. 컴포넌트마다 URL 을 적어두면 변경 시 전부 찾아다녀야 합니다.

모달

레이어 팝업은 *.modal.vue 로 만들고 vue-final-modal 을 사용합니다.

typescript
import { useModal } from 'vue-final-modal';
import BoardDetailModal from './board-detail.modal.vue';

const { open, close } = useModal({
  component: BoardDetailModal,
  attrs: {
    id: row.id,
    onClose: () => close(),
  },
});

관련 문서