InfoGrab DocsInfoGrab Docs

Vuex

요약

Vuex는 GitLab에서 사용 중단되었으며, 새 Vuex 스토어를 만들어서는 안 됩니다. 이 페이지에 담긴 나머지 정보는 공식 Vuex 문서에서 더 자세히 설명합니다. Vuex는 State, Getters, Mutations, Actions, Modules로 구성됩니다.

사용 중단(DEPRECATED)#

Vuex는 GitLab에서 사용 중단되었으며, 새 Vuex 스토어를 만들어서는 안 됩니다. 기존 Vuex 스토어는 계속 유지 관리할 수 있지만, Vuex에서 완전히 마이그레이션하는 것을 강력히 권장합니다.

이 페이지에 담긴 나머지 정보는 공식 Vuex 문서에서 더 자세히 설명합니다.

관심사 분리#

Vuex는 State, Getters, Mutations, Actions, Modules로 구성됩니다.

사용자가 액션을 선택하면 그 액션을 dispatch해야 합니다. 이 액션은 상태를 변경하는 뮤테이션을 commit합니다. 액션 자체는 상태를 업데이트하지 않습니다. 뮤테이션만 상태를 업데이트해야 합니다.

파일 구조#

GitLab에서 Vuex를 사용할 때는 가독성을 높이기 위해 이러한 관심사를 서로 다른 파일로 분리합니다.

└── store
  ├── index.js          # where we assemble modules and export the store
  ├── actions.js        # actions
  ├── mutations.js      # mutations
  ├── getters.js        # getters
  ├── state.js          # state
  └── mutation_types.js # mutation types

다음 예시는 사용자를 나열하고 상태에 추가하는 애플리케이션을 보여줍니다. (더 복잡한 구현 예시는 이 리포지터리에 저장된 보안 애플리케이션을 살펴봅니다.)

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: [],
  });

state 속성 접근#

컴포넌트에서 상태 속성에 접근하려면 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 네임스페이스#

요청을 보낼 때 사용자에게 로딩 상태를 보여주려는 경우가 많습니다.

로딩 상태를 전환하는 뮤테이션을 따로 만드는 대신 다음과 같이 합니다.

  1. 로딩 상태를 전환하는 REQUEST_SOMETHING 타입의 뮤테이션
  2. 성공 콜백을 처리하는 RECEIVE_SOMETHING_SUCCESS 타입의 뮤테이션
  3. 오류 콜백을 처리하는 RECEIVE_SOMETHING_ERROR 타입의 뮤테이션
  4. 요청을 보내고 위에 언급한 경우에 뮤테이션을 커밋하는 fetchSomething 액션
    1. 애플리케이션이 GET 요청 외의 작업도 수행한다면 다음을 예시로 사용할 수 있습니다.
      • POST: createSomething
      • PUT: updateSomething
      • DELETE: deleteSomething

결과적으로 컴포넌트에서 fetchNamespace 액션을 디스패치할 수 있고, 이 액션이 REQUEST_NAMESPACE, RECEIVE_NAMESPACE_SUCCESS, RECEIVE_NAMESPACE_ERROR 뮤테이션을 커밋할 책임을 집니다.

이전에는 뮤테이션을 커밋하는 대신 fetchNamespace 액션에서 액션을 디스패치했으므로, 코드베이스의 오래된 부분에서 다른 패턴을 발견해도 혼동하지 않아도 됩니다. 다만 새 Vuex 스토어를 작성할 때는 새 패턴을 활용하기를 권장합니다.

이 패턴을 따르면 다음을 보장할 수 있습니다.

  1. 모든 애플리케이션이 같은 패턴을 따르므로 누구나 코드를 더 쉽게 유지 관리할 수 있습니다.
  2. 애플리케이션의 모든 데이터가 같은 라이프사이클 패턴을 따릅니다.
  3. 단위 테스트가 더 쉬워집니다.

복잡한 상태 업데이트#

때로는, 특히 상태가 복잡할 때, 뮤테이션이 업데이트해야 하는 지점을 정확히 찾아 상태를 탐색하기가 매우 어렵습니다. 이상적으로는 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을 필터링하는 것처럼, 스토어 상태를 기반으로 파생된 상태를 가져와야 할 때가 있습니다. 게터를 사용하면 계산된 prop의 동작 방식 덕분에 의존성에 따라 결과가 캐시되기도 합니다. 이는 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
});

프론트엔드 코드베이스를 탐색하고 검색하는 능력을 높이기 위해 이 패턴을 의도적으로 피하기로 결정했습니다. 같은 원칙이 Vue 앱에 데이터를 제공할 때도 적용됩니다. 그 이유는 이 논의에 설명되어 있습니다.

스토어 상태에서 someStateKey를 사용하고 있다고 가정합니다. 그 값이 el.dataset으로만 제공됐다면 그 이름으로 직접 grep 하지 못할 수도 있습니다. 대신 some_state_key로 grep 해야 하는데, Rails 템플릿에서 넘어온 값일 수 있기 때문입니다. 반대도 마찬가지입니다. 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 관심사 테스트#

액션, 게터, 뮤테이션 테스트에 대해서는 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을 사용하려면 다음과 같은 형태의 계산된 속성을 만들어야 합니다.

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가 스토어에서 데이터를 가져오고 업데이트 시 올바른 액션을 디스패치하는 적절한 계산된 속성을 생성합니다.

키의 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,
    ),
  }
}

Vuex

GitLab v19.4
원문 보기

요약

Vuex는 GitLab에서 사용 중단되었으며, 새 Vuex 스토어를 만들어서는 안 됩니다. 이 페이지에 담긴 나머지 정보는 공식 Vuex 문서에서 더 자세히 설명합니다. Vuex는 State, Getters, Mutations, Actions, Modules로 구성됩니다.

사용 중단(DEPRECATED)#

Vuex는 GitLab에서 사용 중단되었으며, 새 Vuex 스토어를 만들어서는 안 됩니다. 기존 Vuex 스토어는 계속 유지 관리할 수 있지만, Vuex에서 완전히 마이그레이션하는 것을 강력히 권장합니다.

이 페이지에 담긴 나머지 정보는 공식 Vuex 문서에서 더 자세히 설명합니다.

관심사 분리#

Vuex는 State, Getters, Mutations, Actions, Modules로 구성됩니다.

사용자가 액션을 선택하면 그 액션을 dispatch해야 합니다. 이 액션은 상태를 변경하는 뮤테이션을 commit합니다. 액션 자체는 상태를 업데이트하지 않습니다. 뮤테이션만 상태를 업데이트해야 합니다.

파일 구조#

GitLab에서 Vuex를 사용할 때는 가독성을 높이기 위해 이러한 관심사를 서로 다른 파일로 분리합니다.

└── store
  ├── index.js          # where we assemble modules and export the store
  ├── actions.js        # actions
  ├── mutations.js      # mutations
  ├── getters.js        # getters
  ├── state.js          # state
  └── mutation_types.js # mutation types

다음 예시는 사용자를 나열하고 상태에 추가하는 애플리케이션을 보여줍니다. (더 복잡한 구현 예시는 이 리포지터리에 저장된 보안 애플리케이션을 살펴봅니다.)

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: [],
  });

state 속성 접근#

컴포넌트에서 상태 속성에 접근하려면 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 네임스페이스#

요청을 보낼 때 사용자에게 로딩 상태를 보여주려는 경우가 많습니다.

로딩 상태를 전환하는 뮤테이션을 따로 만드는 대신 다음과 같이 합니다.

  1. 로딩 상태를 전환하는 REQUEST_SOMETHING 타입의 뮤테이션
  2. 성공 콜백을 처리하는 RECEIVE_SOMETHING_SUCCESS 타입의 뮤테이션
  3. 오류 콜백을 처리하는 RECEIVE_SOMETHING_ERROR 타입의 뮤테이션
  4. 요청을 보내고 위에 언급한 경우에 뮤테이션을 커밋하는 fetchSomething 액션
    1. 애플리케이션이 GET 요청 외의 작업도 수행한다면 다음을 예시로 사용할 수 있습니다.
      • POST: createSomething
      • PUT: updateSomething
      • DELETE: deleteSomething

결과적으로 컴포넌트에서 fetchNamespace 액션을 디스패치할 수 있고, 이 액션이 REQUEST_NAMESPACE, RECEIVE_NAMESPACE_SUCCESS, RECEIVE_NAMESPACE_ERROR 뮤테이션을 커밋할 책임을 집니다.

이전에는 뮤테이션을 커밋하는 대신 fetchNamespace 액션에서 액션을 디스패치했으므로, 코드베이스의 오래된 부분에서 다른 패턴을 발견해도 혼동하지 않아도 됩니다. 다만 새 Vuex 스토어를 작성할 때는 새 패턴을 활용하기를 권장합니다.

이 패턴을 따르면 다음을 보장할 수 있습니다.

  1. 모든 애플리케이션이 같은 패턴을 따르므로 누구나 코드를 더 쉽게 유지 관리할 수 있습니다.
  2. 애플리케이션의 모든 데이터가 같은 라이프사이클 패턴을 따릅니다.
  3. 단위 테스트가 더 쉬워집니다.

복잡한 상태 업데이트#

때로는, 특히 상태가 복잡할 때, 뮤테이션이 업데이트해야 하는 지점을 정확히 찾아 상태를 탐색하기가 매우 어렵습니다. 이상적으로는 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을 필터링하는 것처럼, 스토어 상태를 기반으로 파생된 상태를 가져와야 할 때가 있습니다. 게터를 사용하면 계산된 prop의 동작 방식 덕분에 의존성에 따라 결과가 캐시되기도 합니다. 이는 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
});

프론트엔드 코드베이스를 탐색하고 검색하는 능력을 높이기 위해 이 패턴을 의도적으로 피하기로 결정했습니다. 같은 원칙이 Vue 앱에 데이터를 제공할 때도 적용됩니다. 그 이유는 이 논의에 설명되어 있습니다.

스토어 상태에서 someStateKey를 사용하고 있다고 가정합니다. 그 값이 el.dataset으로만 제공됐다면 그 이름으로 직접 grep 하지 못할 수도 있습니다. 대신 some_state_key로 grep 해야 하는데, Rails 템플릿에서 넘어온 값일 수 있기 때문입니다. 반대도 마찬가지입니다. 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 관심사 테스트#

액션, 게터, 뮤테이션 테스트에 대해서는 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을 사용하려면 다음과 같은 형태의 계산된 속성을 만들어야 합니다.

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가 스토어에서 데이터를 가져오고 업데이트 시 올바른 액션을 디스패치하는 적절한 계산된 속성을 생성합니다.

키의 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,
    ),
  }
}