Vuex
GitLab v19.1Vuex는 GitLab에서 사용 중단(deprecated)되었으며 새로운 Vuex 스토어를 생성해서는 안 됩니다. 이 페이지에 포함된 나머지 정보는 공식 Vuex 문서에서 더 자세히 설명하고 있습니다. Vuex는 State, Getters, Mutations, Actions, Modules로 구성됩니다.
사용 중단(DEPRECATED)#
Vuex는 GitLab에서 사용 중단(deprecated)되었으며 새로운 Vuex 스토어를 생성해서는 안 됩니다. 기존 Vuex 스토어는 계속 유지 관리할 수 있지만, Vuex를 완전히 마이그레이션하는 것을 강력히 권장합니다.
이 페이지에 포함된 나머지 정보는 공식 Vuex 문서에서 더 자세히 설명하고 있습니다.
관심사 분리#
Vuex는 State, Getters, Mutations, Actions, Modules로 구성됩니다.
사용자가 액션을 선택하면 해당 액션을 dispatch해야 합니다. 이 액션은 상태를 변경하는 뮤테이션을 commit합니다. 액션 자체는 상태를 업데이트하지 않으며, 오직 뮤테이션만 상태를 업데이트해야 합니다.
파일 구조#
GitLab에서 Vuex를 사용할 때는 가독성을 높이기 위해 다음과 같이 관심사를 별도 파일로 분리하세요:
└── store
├── index.js # 모듈을 조합하고 스토어를 내보내는 파일
├── actions.js # 액션
├── mutations.js # 뮤테이션
├── getters.js # 게터
├── state.js # 상태
└── mutation_types.js # 뮤테이션 타입
아래 예시는 사용자를 나열하고 상태에 추가하는 애플리케이션을 보여줍니다. (더 복잡한 구현 예시는 이 리포지터리에 저장된 보안 애플리케이션을 참고하세요.)
index.js#
스토어의 진입점입니다. 다음을 가이드로 활용할 수 있습니다:
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export const createStore = () =>
new Vuex.Store({
actions,
getters,
mutations,
state,
});
state.js#
코드를 작성하기 전에 가장 먼저 해야 할 일은 상태를 설계하는 것입니다.
종종 HAML에서 Vue 애플리케이션으로 데이터를 전달해야 합니다. 더 쉽게 접근하기 위해 상태에 저장합니다.
export default () => ({
endpoint: null,
isLoading: false,
error: null,
isAddingUser: false,
errorAddingUser: false,
users: [],
});
상태 속성 접근#
컴포넌트에서 상태 속성에 접근하려면 mapState를 사용할 수 있습니다.
actions.js#
액션은 애플리케이션에서 스토어로 데이터를 전송하는 정보 페이로드입니다.
액션은 일반적으로 type과 payload로 구성되며 발생한 일을 설명합니다. 뮤테이션과 달리, 액션은 비동기 작업을 포함할 수 있으므로 항상 액션에서 비동기 로직을 처리해야 합니다.
이 파일에서는 사용자 목록을 처리하는 뮤테이션을 호출하는 액션을 작성합니다:
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { createAlert } from '~/alert';
export const fetchUsers = ({ state, dispatch }) => {
commit(types.REQUEST_USERS);
axios.get(state.endpoint)
.then(({ data }) => commit(types.RECEIVE_USERS_SUCCESS, data))
.catch((error) => {
commit(types.RECEIVE_USERS_ERROR, error)
createAlert({ message: 'There was an error' })
});
}
export const addUser = ({ state, dispatch }, user) => {
commit(types.REQUEST_ADD_USER);
axios.post(state.endpoint, user)
.then(({ data }) => commit(types.RECEIVE_ADD_USER_SUCCESS, data))
.catch((error) => commit(types.REQUEST_ADD_USER_ERROR, error));
}
액션 디스패치#
컴포넌트에서 액션을 디스패치하려면 mapActions 헬퍼를 사용하세요:
import { mapActions } from 'vuex';
{
methods: {
...mapActions([
'addUser',
]),
onClickUser(user) {
this.addUser(user);
},
},
};
mutations.js#
뮤테이션은 스토어에 전송된 액션에 응답하여 애플리케이션 상태가 어떻게 변경되는지를 명세합니다. Vuex 스토어에서 상태를 변경하는 유일한 방법은 뮤테이션을 커밋하는 것입니다.
대부분의 뮤테이션은 commit을 사용하여 액션에서 커밋됩니다. 비동기 작업이 없는 경우 mapMutations 헬퍼를 사용하여 컴포넌트에서 뮤테이션을 호출할 수 있습니다.
컴포넌트에서 뮤테이션 커밋 예시는 Vuex 문서를 참고하세요.
네이밍 패턴: REQUEST와 RECEIVE 네임스페이스#
요청이 이루어질 때 사용자에게 로딩 상태를 표시하고 싶은 경우가 많습니다.
로딩 상태를 전환하는 뮤테이션을 별도로 만드는 대신 다음과 같이 해야 합니다:
-
로딩 상태를 전환하기 위한
REQUEST_SOMETHING타입의 뮤테이션 -
성공 콜백을 처리하기 위한
RECEIVE_SOMETHING_SUCCESS타입의 뮤테이션 -
에러 콜백을 처리하기 위한
RECEIVE_SOMETHING_ERROR타입의 뮤테이션 -
요청을 만들고 위에 언급된 경우에 뮤테이션을 커밋하는
fetchSomething액션
애플리케이션이 GET 요청 이상을 수행하는 경우 다음을 예시로 사용할 수 있습니다:
POST: createSomething
-
PUT:updateSomething -
DELETE:deleteSomething
결과적으로 컴포넌트에서 fetchNamespace 액션을 디스패치할 수 있으며, 이 액션은 REQUEST_NAMESPACE, RECEIVE_NAMESPACE_SUCCESS, RECEIVE_NAMESPACE_ERROR 뮤테이션을 커밋할 책임이 있습니다.
이전에는 fetchNamespace 액션에서 뮤테이션을 커밋하는 대신 액션을 디스패치하는 방식을 사용했으므로, 코드베이스의 오래된 부분에서 다른 패턴을 발견하더라도 혼동하지 마세요. 그러나 새로운 Vuex 스토어를 작성할 때는 새로운 패턴을 활용하는 것을 권장합니다.
이 패턴을 따르면 다음을 보장합니다:
-
모든 애플리케이션이 동일한 패턴을 따르므로 누구든 코드를 유지 관리하기 더 쉽습니다.
-
애플리케이션의 모든 데이터가 동일한 라이프사이클 패턴을 따릅니다.
-
단위 테스트가 더 쉬워집니다.
복잡한 상태 업데이트#
때로는, 특히 상태가 복잡할 때, 뮤테이션이 업데이트해야 할 부분을 정확히 찾아 상태를 탐색하기가 매우 어렵습니다.
이상적으로 vuex 상태는 가능한 한 정규화/분리되어야 하지만 항상 그렇지는 않습니다.
변경되는 상태의 일부를 뮤테이션 자체에서 선택하고 변경할 때 코드를 읽고 유지 관리하기 훨씬 쉽다는 것을 기억하는 것이 중요합니다.
다음과 같은 상태가 있다고 가정합니다:
export default () => ({
items: [
{
id: 1,
name: 'my_issue',
closed: false,
},
{
id: 2,
name: 'another_issue',
closed: false,
}
]
});
다음과 같이 뮤테이션을 작성하고 싶을 수 있습니다:
// Bad
export default {
[types.MARK_AS_CLOSED](state, item) {
Object.assign(item, {closed: true})
}
}
이 방법은 작동하지만 여러 의존성이 있습니다:
-
컴포넌트/액션에서
item을 올바르게 선택해야 합니다. -
item속성이 이미closed상태에 선언되어 있어야 합니다.
새로운 confidential 속성은 반응형이 되지 않습니다.
item이items에 의해 참조된다는 점에 주의해야 합니다.
이런 방식으로 작성된 뮤테이션은 유지 관리하기 어렵고 오류가 발생하기 쉽습니다. 대신 다음과 같이 뮤테이션을 작성해야 합니다:
// Good
export default {
[types.MARK_AS_CLOSED](state, itemId) {
const item = state.items.find(x => x.id === itemId);
if (!item) {
return;
}
Vue.set(item, 'closed', true);
},
};
이 방법이 더 좋은 이유:
-
뮤테이션에서 상태를 선택하고 업데이트하므로 더 유지 관리하기 좋습니다.
-
외부 의존성이 없으며, 올바른
itemId가 전달되면 상태가 올바르게 업데이트됩니다. -
초기 상태와의 결합을 피하기 위해 새로운
item을 생성하므로 반응성 문제가 없습니다.
이런 방식으로 작성된 뮤테이션은 유지 관리하기 더 쉽습니다. 또한, 반응성 시스템의 제한으로 인한 오류를 방지할 수 있습니다.
getters.js#
때로는 특정 prop으로 필터링하는 것처럼 스토어 상태를 기반으로 파생된 상태가 필요할 수 있습니다.
게터를 사용하면 computed 속성 동작 방식으로 인해 의존성을 기반으로 결과를 캐시하기도 합니다.
getters를 통해 수행할 수 있습니다:
// get all the users with pets
export const getUsersWithPets = (state, getters) => {
return state.users.filter(user => user.pet !== undefined);
};
컴포넌트에서 게터에 접근하려면 mapGetters 헬퍼를 사용하세요:
import { mapGetters } from 'vuex';
{
computed: {
...mapGetters([
'getUsersWithPets',
]),
},
};
mutation_types.js#
Vuex 뮤테이션 문서에서 인용:
다양한 Flux 구현에서 뮤테이션 타입에 상수를 사용하는 것은 일반적으로 볼 수 있는 패턴입니다. 이를 통해 코드가 린터와 같은 도구를 활용할 수 있으며, 모든 상수를 하나의 파일에 배치하면 협력자가 전체 애플리케이션에서 가능한 뮤테이션을 한눈에 파악할 수 있습니다.
export const ADD_USER = 'ADD_USER';
스토어 상태 초기화#
Vuex 스토어는 action을 사용하기 전에 일부 초기 상태가 필요한 경우가 많습니다.
일반적으로 API 엔드포인트, 문서 URL, ID 같은 데이터가 포함됩니다.
이 초기 상태를 설정하려면 Vue 컴포넌트를 마운트할 때 스토어의 생성 함수에 파라미터로 전달하세요:
// in the Vue app's initialization script (for example, mount_show.js)
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { createStore } from './stores';
import AwesomeVueApp from './components/awesome_vue_app.vue'
Vue.use(Vuex);
export default () => {
const el = document.getElementById('js-awesome-vue-app');
return new Vue({
el,
name: 'AwesomeVueRoot',
store: createStore(el.dataset),
render: h => h(AwesomeVueApp)
});
};
그러면 스토어 함수는 이 데이터를 상태 생성 함수에 전달할 수 있습니다:
// in store/index.js
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
export default initialState => ({
actions,
mutations,
state: createState(initialState),
});
그리고 상태 함수는 이 초기 데이터를 파라미터로 받아 반환하는 state 객체에 포함시킬 수 있습니다:
// in store/state.js
export default ({
projectId,
documentationPath,
anOptionalProperty = true
}) => ({
projectId,
documentationPath,
anOptionalProperty,
// other state properties here
});
초기 상태를 스프레드하지 않는 이유#
주의 깊은 독자라면 위 예시에서 몇 줄의 코드를 줄일 기회를 발견할 것입니다:
// Don't do this!
export default initialState => ({
...initialState,
// other state properties here
});
우리는 프론트엔드 코드베이스를 더 쉽게 발견하고 검색할 수 있도록 이 패턴을 의도적으로 피하기로 결정했습니다. 동일한 원칙이 HAML에서 Vue 앱으로 데이터를 제공할 때에도 적용됩니다. 이에 대한 이유는 이 토론에서 설명합니다:
스토어 상태에서 someStateKey가 사용되고 있다고 가정합니다. el.dataset에서만 제공된 경우 직접 grep으로 찾지 못할 수도 있습니다. 대신 Rails 템플릿에서 왔을 수 있으므로 some_state_key로 grep해야 합니다. 반대의 경우도 마찬가지입니다: Rails 템플릿을 보면서 some_state_key를 무엇이 사용하는지 궁금할 수 있지만, someStateKey로 grep해야 합니다.
스토어와의 통신#
<script>
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
export default {
computed: {
...mapGetters([
'getUsersWithPets'
]),
...mapState([
'isLoading',
'users',
'error',
]),
},
methods: {
...mapActions([
'fetchUsers',
'addUser',
]),
onClickAddUser(data) {
this.addUser(data);
}
},
created() {
this.fetchUsers()
}
}
</script>
<template>
<ul>
<li v-if="isLoading">
Loading...
</li>
<li v-else-if="error">
{{ error }}
</li>
<template v-else>
<li
v-for="user in users"
:key="user.id"
>
{{ user }}
</li>
</template>
</ul>
</template>
Vuex 테스트#
Vuex 관심사 테스트#
Actions, Getters, Mutations 테스트에 관해서는 Vuex 문서를 참고하세요.
스토어가 필요한 컴포넌트 테스트#
소규모 컴포넌트는 store 속성을 사용하여 데이터에 접근할 수 있습니다. 이런 컴포넌트에 대한 단위 테스트를 작성하려면 스토어를 포함하고 올바른 상태를 제공해야 합니다:
//component_spec.js
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { mount } from '@vue/test-utils';
import { createStore } from './store';
import Component from './component.vue'
Vue.use(Vuex);
describe('component', () => {
let store;
let wrapper;
const createComponent = () => {
store = createStore();
wrapper = mount(Component, {
store,
});
};
beforeEach(() => {
createComponent();
});
it('should show a user', async () => {
const user = {
name: 'Foo',
age: '30',
};
// populate the store
await store.dispatch('addUser', user);
expect(wrapper.text()).toContain(user.name);
});
});
일부 테스트 파일은 여전히 @vue/test-utils의 사용 중단된 createLocalVue 함수와 localVue.use(Vuex)를 사용할 수 있습니다. 이는 불필요하며, 가능하면 피하거나 제거해야 합니다.
양방향 데이터 바인딩#
Vuex에 폼 데이터를 저장할 때 저장된 값을 업데이트해야 하는 경우가 있습니다. 스토어를 직접 변경해서는 안 되며 대신 액션을 사용해야 합니다.
코드에서 v-model을 사용하려면 다음 형식으로 computed 속성을 만들어야 합니다:
export default {
computed: {
someValue: {
get() {
return this.$store.state.someValue;
},
set(value) {
this.$store.dispatch("setSomeValue", value);
}
}
}
};
대안으로 mapState와 mapActions를 사용하는 방법도 있습니다:
export default {
computed: {
...mapState(['someValue']),
localSomeValue: {
get() {
return this.someValue;
},
set(value) {
this.setSomeValue(value)
}
}
},
methods: {
...mapActions(['setSomeValue'])
}
};
이런 속성을 몇 개 추가하면 번거로워지고, 코드가 더 반복적이 되며 더 많은 테스트를 작성해야 합니다. 이를 단순화하기 위해 ~/vuex_shared/bindings.js에 헬퍼가 있습니다.
헬퍼는 다음과 같이 사용할 수 있습니다:
// this store is non-functional and only used to give context to the example
export default {
state: {
baz: '',
bar: '',
foo: ''
},
actions: {
updateBar() {...},
updateAll() {...},
},
getters: {
getFoo() {...},
}
}
import { mapComputed } from '~/vuex_shared/bindings'
export default {
computed: {
/**
* @param {(string[]|Object[])} list - list of string matching state keys or list objects
* @param {string} list[].key - the key matching the key present in the vuex state
* @param {string} list[].getter - the name of the getter, leave it empty to not use a getter
* @param {string} list[].updateFn - the name of the action, leave it empty to use the default action
* @param {string} defaultUpdateFn - the default function to dispatch
* @param {string|function} root - optional key of the state where to search for they keys described in list
* @returns {Object} a dictionary with all the computed properties generated
*/
...mapComputed(
[
'baz',
{ key: 'bar', updateFn: 'updateBar' },
{ key: 'foo', getter: 'getFoo' },
],
'updateAll',
),
}
}
mapComputed는 스토어에서 데이터를 가져오고 업데이트 시 올바른 액션을 디스패치하는 적절한 computed 속성을 생성합니다.
키의 root가 한 단계 이상 깊은 경우 관련 상태 객체를 가져오는 함수를 사용할 수 있습니다.
예를 들어, 다음과 같은 스토어가 있다고 가정합니다:
// this store is non-functional and only used to give context to the example
export default {
state: {
foo: {
qux: {
baz: '',
bar: '',
foo: '',
},
},
},
actions: {
updateBar() {...},
updateAll() {...},
},
getters: {
getFoo() {...},
}
}
root는 다음과 같이 될 수 있습니다:
import { mapComputed } from '~/vuex_shared/bindings'
export default {
computed: {
...mapComputed(
[
'baz',
{ key: 'bar', updateFn: 'updateBar' },
{ key: 'foo', getter: 'getFoo' },
],
'updateAll',
(state) => state.foo.qux,
),
}
}