본문 바로가기

프로그래밍(TA, AA)/자바스크립트

[Vue.js] Vuex를 이용한 애플리케이션 작성

아래는 예제 애플리케이션에 대한 constant를 정의한 것이다. 상태를 변경하는 작업(mutation)과 action을 모두 찾아 등록한다.

export default {
	FETCH_CONTACTS: "fetchContacts",			// 연락처 조회
	ADD_CONTACT_FORM: "addContactForm",			// 입력폼 나타내기
	ADD_CONTACT: "addContact",					// 연락처 추가
	EDIT_CONTACT_FORM: "editContactForm",		// 수정폼 나타내기
	UPDATE_CONTACT: "updateContact",			// 연락처 수정
	CANCEL_FORM: "cancelForm",					// 입력, 수정폼 닫기
	EDIT_PHOTO_FORM: "editPhotoForm",			// 사진 편집폼 나타내기
	UPDATE_PHOTO: "updatePhoto",				// 사진 수정
	DELETE_CONTACT: "deleteContact",			// 연락처 삭제
}

 

이제 저장소(store)를 작성한다. 상태(state) 데이터는 App.vue의 data 옵션을 그대로 사용한다. 변이(mutation)은 상태를 변경하는 작업을 등록하면 된다. 액션은 ContactsAPI를 호출하기도 한다. 먼저 state부터 작성한다.

import Constant from '../constant';
import CONF from '../Config.js';

export default {
	currentView: null,
	mode: 'add',
	contact: { no:0, name:'', tel:'', address:'', photo:'' },
	contactlist: {
		pageno: 1, pagesize: CONF.PAGESIZE, totalcount: 0, contacts: []
	}
}

 

다음으로 mutations.js 파일을 생성하고 변이 코드를 작성한다.

import Constant from '../constant';

// 상태를 변경하는 기능만 추린다.
export default {
	[Constant.ADD_CONTACT_FORM] : (state) => {
		state.contact = { no:'', name:'', tel:'', address:'', photo:'' };
		state.mode = "add";
		state.currentView = "contactForm";
	},
	[Constant.CANCEL_FORM] : (state) => {
		state.currentView = null;
	},
	[Constant.EDIT_CONTACT_FORM] : (state, payload) => {
		state.contact = payload.contact;
		state.mode = "update";
		state.currentView = "contactForm";
	},
	[Constant.EDIT_PHOTO_FORM] : (state, payload) => {
		state.contact = payload.contact;
		state.currentView = "updatePhoto";
	},
	[Constant.FETCH_CONTACTS] : (state, payload) => {
		state.contactlist = payload.contactlist;
	}
}

 

이제 액션 코드를 작성한다. 액션 기능에서 외부 API와 비동기 방식으로 통신하도록 한다.

import contactAPI from '../api/ContactsAPI';
import Constant from '../constant';

export default {
	[Constant.ADD_CONTACT_FORM] : (store) => {
		store.commit(Constant.ADD_CONTACT_FORM);
	},
	[Constant.ADD_CONTACT] : (store) => {
		contactAPI.addContact(store.state.contact)
		.then((response) => {
			if (response.data.status == "success") {
				store.dispatch(Constant.CANCEL_FORM);
				store.dispatch(Constant.FETCH_CONTACTS, { pageno: 1 });
			} else {
				console.log("연락처 추가 실패 : " + response.data);
			}
		})
	},
	[Constant.EDIT_CONTACT_FORM] : (store, payload) => {
		contactAPI.fetchContactOne(payload.no)
		.then((response) => {
			store.commit(Constant.EDIT_CONTACT_FORM, { contact: response.data });
		})
	},
	[Constant.UPDATE_CONTACT] : (store) => {
		var currentPageNo = store.state.contactlist.pageno;
		contactAPI.updateContact(store.state.contact)
		.then((response) => {
			if (response.data.status == "success") {
				store.dispatch(Constant.CANCEL_FORM);
				store.dispatch(Constant.FETCH_CONTACTS, { pageno: currentPageNo })
			} else {
				console.log("연락처 변경 실패 : " + response.data);
			}
		})
	},
	[Constant.EDIT_PHOTO_FORM] : (store, payload) => {
		contactAPI.fetchContactOne(payload.no)
		.then((response) => {
			store.commit(Constant.EDIT_PHOTO_FORM, { contact: response.data });
		})
	},
	[Constant.UPDATE_PHOTO] : (store, payload) => {
		var currentPageNo = store.state.contactlist.pageno;
		contactAPI.updatePhoto(payload.no, payload.file)
		.then((response) => {
			store.dispatch(Constant.CANCEL_FORM);
			store.dispatch(Constant.FETCH_CONTACTS, { pageno : currentPageNo });
		})
	},
	[Constant.FETCH_CONTACTS] : (store, payload) => {
		var pageno;
		if (typeof payload === "undefined" || typeof payload.pageno === "undefined") {
			pageno = 1;
		} else {
			pageno = payload.pageno;
		}
		var pagesize = store.state.contactlist.pagesize;

		contactAPI.fetchContacts(pageno, pagesize)
		.then((response) => {
			store.commit(Constant.FETCH_CONTACTS, { contactlist: response.data });
		})
	},
	[Constant.CANCEL_FORM] : (store, payload) => {
		store.commit(Constant.CANCEL_FORM);
	},
	[Constant.DELETE_CONTACT] : (store, payload) => {
		var currentPageNo = store.state.contactlist.pageno;
		contactAPI.deleteContanct(payload.no)
		.then((response) => {
			store.dispatch(Constant.FETCH_CONTACTS, { pageno: currentPageNo });
		})
	},
	[Constant.CHANGE_MODE] : (store, payload) => {
		store.commit(Constant.CHANGE_MODE, { mode: payload.mode });
	}
}

 

이제 상태, 변이 액션 객체를 취합해서 store 객체를 생성한다.

import Vue from 'vue';
import Vuex from 'vuex';
import state from './state.js';
import mutations from './mutations.js';
import actions from './actions.js';
import ES6Promise from 'es6-promise';

ES6Promise.polyfill();

Vue.use(Vuex);

const store = new Vuex.Store({
	state,
	mutations,
	actions
})

export default store;

 

state, mutation, action과 이를 이용하는 store 객체를 작성했다. mutation는 state가 변경되는 상황을 정의하면 된다. mutation 메서드의 두번째 인자인 payload를 통해 변경하려는 상태 정보를 받아 상태를 변경한다. action은 화면 UI에서의 작업을 먼저 고려하고 action이 일어날 때 호출해야 하는 외부 API와 변경해야 할 state를 고려해야 한다.

 

updateContact 액션의 경우와 같이 자체적으로 상태를 변경하지 않고 다른 액션(cancelForm, fetchContacts)을 호출하는 경우도 있을 수 있다. (다른 액션 호출: dispatch)

 

이제 이벤트 버스 객체는 더이상 필요하지 않다. 각 컴포넌트에서 Vuex의 상태를 계산형 속성(Computed Property)에 바인딩하고 이것을 이용해 UI를 구성하면 된다. 컴포넌트에서 액션이 발생하는 경우에는 this.$store.dispatch() 메서드로 액션을 호출한다.

<template>
	<div id="container">
		<div class="page-header">
			<h1 class="text-center">연락처 관리 애플리케이션</h1>
			<p>(Dynamic Component + Vuex + Axios)</p>
		</div>
		<component :is="currentView"></component>
		<contactList></contactList>
		<paginate ref="pagebuttons"
			:page-count="totalpage"
			:page-range="7"
			:margin-pages="3"
			:click-handler="pageChanged"
			:prev-text="'이전'"
			:next-text="'다음'"
			:container-class="'pagination'"
			:page-class="'page-item'">
		</paginate>
	</div>
</template>

<script>
import ContactList from './components/ContactList';
import ContactForm from './components/ContactForm';
import UpdatePhoto from './components/UpdatePhoto';

import CONF from './Config.js';
import Constant from './constant';
import Paginate from 'vuejs-paginate';
import _ from 'lodash';
import { mapState } from 'vuex';

export default {
	name: 'app',
	components: { ContactList, ContactForm, UpdatePhoto, Paginate },
	computed: _.extend({
			totalpage : function() {
				var totalcount = this.contactlist.totalcount;
				var pagesize = this.contactlist.pagesize;
				return Math.floor((totalcount - 1) / pagesize) + 1;
			}
		}, mapState([
			'contactlist', 'currentView'
		])
	),
	watch: function() {
		'contactlist.pageno' : function(page) {
			this.$refs.pagebuttons.selected = page-1;
		}
	},
	mounted: function() {
		this.$store.dispatch(Constant.FETCH_CONTACTS);
	},
	methods: {
		pageChanged : function(page) {
			this.$store.dispatch(Constant.FETCH_CONTACTS, { pageno: page })
		}
	}
}
</script>
<style scoped></style>

 

App.vue에서의 가장 큰 변화는 data 옵션 대신에 Computed Property가 추가되었다는 점이다. mapState 헬퍼 메서드를 이용해서 Vuex의 상태 데이터를 계산형 속성에 바인딩 한다. 또한 mounted 이벤트 훅에서의 지저분한 이벤트 수신 코드가 사라졌다.

 

템플릿 문자열에서도 변화가 있다. props를 통해서 자식 컴포넌트에 상태데이터를 전달해줘야 했지만 하위 컴포넌트가 자체적으로 Vuex의 상태 데이터를 바인딩할 것이기 때문이다.

 

이제 ContactList.vue 등 자식 컴포넌트들을 변경한다.

<template></template>

<script>
import Constant from '../constant';
import { mapState } from 'vuex';

export default {
	name: 'contactList',
	computed: mapState(['contactlist']),
	methods: {
		addContact: function() {
			this.$store.dispatch(Constant.ADD_CONTACT_FORM);
		},
		editContact: function(no) {
			this.$store.dispatch(Constant.EDIT_CONTACT_FORM, );
		},
		deleteContact: function(no) {
			if (confirm("정말로 삭제하시겠습니까?") == true) {
				this.$store.dispatch(Constant.DELETE_CONTACT, );
			}
		},
		editPhoto: function(no) {
			this.$store.dispatch(Constant.EDIT_PHOTO_FORM, );
		}
	}
}
</script>
<style scoped></style>

 

ContactList.vue는 props를 통해서 필요한 데이터를 전달받는 것이 아니라 Vuex의 상태 데이터를 계산형 속성으로 바인딩한다. 또한 컴포넌트 UI에서 이벤트가 발생할 때 저장소의 액션을 호출하도록 변경해야 한다.

<template></template>

<script>
import Constant from '../constant';
import { mapState } from 'vuex';
import _ from 'lodash';

export default {
	name: "contactForm",
	computed: _.extend({
			btnText: function() {
				if (this.mode != 'update') {
					return '추 가';
				} else {
					return '수 정';
				}
			},
			headingText: function() {
				if (this.mode != 'update') {
					return '새로운 연락처 추가';
				} else {
					return '연락처 변경';
				}
			}
		},
		mapState(['mode', 'contact'])
	),
	mounted: function() {
		this.$refs.name.focuse();
	},
	methods: {
		submitEvent: function() {
			if (this.mode == "update") {
				this.$store.dispatch(Constant.UPDATE_CONTACT);
			} else {
				this.$store.dispatch(Constant.ADD_CONTACT);
			}
		},
		cancelEvent: function() {
			this.$store.dispatch(Constant.CANCEL_FORM);
		}
	}
}
</script>
<style scoped></style>

 

ContactForm.vue 컴포넌트는 mapState 헬퍼 메서드를 이용해 mode, contact 상태 데이터를 계산형 속성에 바인딩한다. 컴포넌트 UI에서 이벤트가 발생하면 this.$store.dispatch() 메서드를 이용해 적절한 액션을 호출하면 된다.

 

UpdatePhoto.vue 컴포넌트는 ContactForm.vue와 구조가 유사하다. 스타일을 제외한 전체 코드는 다음과 같다.

<template>
<div class="modal">
	<div class="form" @keyup.esc="cancelEvent">
		<form method="post" enctype="multipart/form-data">
			<h3 class="heading">:: 사진 변경</h3>
			<input type="hidden" name="no" class="long" disabled v-model="contact.no" />
			<div>
				<label>현재 사진</label>
				<img class="thumb" :src="contact.photo" />
			</div>
			<div>
				<label>사진 파일 선택</label>
				<label>
					<input ref="photofile" type="file" name="photo"
						class="long btn btn-default" />
				</label>
			</div>
			<div>
				<div>&nbps;</div>
				<input type="button" class="btn btn-primary" value="변 경"
					@click="photoSubmit()" />
				<input type="button" class="btn btn-primary" value="취 소"
					@click="cancelEvent" />
			</div>
		</form>
	</div>
</div>
</template>

<script>
import Constant from '../constant';
import { mapState } from 'vuex';

export default {
	name: "updatePhoto",
	computed: mapState(['contact']),
	methods: {
		cancelEvent: function() {
			this.$store.dispatch(Constant.CANCEL_FORM);
		},
		photoSubmit: function() {
			var file = this.$refs.photofile.files[0];
			this.$store.dispatch(Constant.UPDATE_PHOTO, { no:this.contact.no, file:file });
		}
	}
}
</script>
<style scoped></style>

 

import Vue from 'vue';
import App from './App';
import store from './store';

Vue.config.productionTip = false;

new Vue({
	store,
	el: '#app',
	template: '<App />',
	components: { App }
})

 

npm run dev 명령어로 예제를 실행하고 Vue devtools를 열어서 Vuex의 변이 정보를 확인하며 액션을 일으켜 본다. 시간 순으로 변이가 일어나고 그때마다 상태와 변이 정보를 확인할 수 있다. 

 

컴포넌트 → 액션 → 변이 → 상태 → 컴포넌트

 

 

단방향 데이터 흐름 과정을 염두에 두고 개발하면 그리 어렵지 않다. 항상 중심이 되는 것은 상태(state)이다. 상태를 중심으로 변이(상태를 변경시키는 상황)을 정의하고, 컴포넌트 UI에서의 액션이 외부 API를 호출한 후 변이를 일으키도록 작성하면 된다.

 


 

v-model 디렉티브를 직접 이용하는 것은 Vuex의 사앹 데이터를 직접 변경하게 된다. 가능하다면 이와 같은 방법은 사용하지 않는 것이 바람직하다. 이런 경우를 예방하기 위해 Vuex 저장소 객체를 만들때 strict 값을 true로 지정할 수 있다.

const store = new Vuex.Store({
	state,
	mutations,
	actions,
	strict: true
})

export default store;

이렇게 설정하고 실행해보면 입력폼/수정폼에서 값을 입력하는 순간 오류가 발생한다. 원칙적으로 허용하지 않도록 설정(기본 strict 설정값이 false)된 것이다. 이문제를 해결하는 방법으로 v-model 디텍티브를 사용하는 대신 contact 상태를 변경하는 mutation과 action을 추가한뒤 텍스트 필드에서 값이 변경되면 changed 이벤트 등을 통해 mutation되도록 작성하는 방법이 있다.

 

또다른 방법으로는 컴포넌트에서 로컬 상태 데이터를 정의해 컴포넌트가 만들어질때 created와 같은 이벤트 훅에서 Vuex의 상태 데이터를 로컬 상태 데이터에 복사한 뒤 로컬 상태를 v-model 디렉티브로 양방향 바인딩한다. 그후 컴포넌트 UI에서 이벤트가 발생하면 로컬 상태 데이터를 payload로 전달하여 액션을 디스패치한 후 변이를 일으켜 상태를 변경할 수 있다.

 

https://github.com/stepanowon/vuejs_book_2nd/tree/master/examples/ch11/contactsapp_strict