컴포넌트 작성 규칙
프로젝트의 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-groupimport 는 대부분 불필요합니다
unplugin-vue-components 가 src/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>| 순서 | 내용 |
|---|---|
| 1 | Imports |
| 2 | Props & Models & Emits — 컴포넌트의 인터페이스 |
| 3 | Hooks & Stores & Composables |
| 4 | State (ref / reactive) |
| 5 | Computed |
| 6 | Methods |
| 7 | Lifecycle Hooks |
| 8 | Watchers |
| 9 | Expose |
필수 패턴
v-model → defineModel
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(),
},
});관련 문서
- Vue3 + TypeScript — 문법 레퍼런스
- 권한 설정 — 화면·API 권한