Vuex

사용 중단

Vuex는 GitLab에서 사용 중단되었습니다. 새로운 Vuex 저장소는 생성하지 않아야 합니다.

기존 Vuex 저장소는 유지할 수 있지만, Vuex에서 완전히 마이그레이션하는 것을 강력히 권장합니다.

이 페이지에 포함된 나머지 정보는 공식 Vuex 문서에서 더 자세히 설명되어 있습니다.

관심사의 분리

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

사용자가 작업을 선택하면 이를 dispatch해야 합니다. 이 작업은 상태를 변경하는 mutation을 commit합니다. 작업 자체는 상태를 업데이트하지 않으며, 오직 mutation만 상태를 업데이트해야 합니다.

파일 구조

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

└── store
  ├── index.js          # 모듈을 조합하고 저장소를 내보내는 곳
  ├── actions.js        # 작업
  ├── mutations.js      # 변경
  ├── getters.js        # getter
  ├── 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: [],
  });

state 속성 접근

mapState를 사용하여 구성 요소에서 상태 속성에 접근할 수 있습니다.

actions.js

작업은 애플리케이션에서 저장소로 데이터를 전송하는 정보의 페이로드입니다.

작업은 일반적으로 typepayload로 구성되며, 이는 발생한 일을 설명합니다. Mutation과는 달리, 작업은 비동기 작업을 포함할 수 있으므로 항상 비동기 로직을 작업 내에서 처리해야 합니다.

이 파일에서는 사용자 목록을 처리하기 위해 mutation을 호출하는 작업을 작성합니다:

  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: '오류가 발생했습니다.' })
      });
  }

  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 문서를 참조하세요.

명명 패턴: REQUESTRECEIVE 네임스페이스

요청이 이루어질 때, 우리는 종종 사용자에게 로딩 상태를 보여주고 싶습니다.

로딩 상태를 토글하기 위해 뮤테이션을 생성하는 대신, 우리는:

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

다음과 같은 뮤테이션을 작성하는 것은 유혹적일 수 있습니다:

// 나쁨
export default {
  [types.MARK_AS_CLOSED](state, item) {
    Object.assign(item, {closed: true})
  }
}

이러한 접근 방식은 작동하지만 여러 가지 의존성이 있습니다:

  • 컴포넌트/액션에서 item을 올바르게 선택해야 합니다.
  • item 속성이 closed 상태에서 이미 선언되어 있습니다.
    • 새로운 confidential 속성은 반응적이지 않습니다.
  • items에 의해 참조되는 item이 있다는 점을 유의하세요.

이렇게 작성된 뮤테이션은 유지 관리가 더 어렵고 오류가 발생하기 쉽습니다. 차라리 다음과 같이 뮤테이션을 작성하는 것이 좋습니다:

// 좋음
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

때때로 우리는 스토어 상태에 따라 파생된 상태를 가져와야 할 수 있으며, 특정 속성을 필터링하는 것이 그것입니다.

getter를 사용하면 계산된 속성의 작동 방식으로 인해 의존성을 기반으로 결과를 캐시합니다.

다음과 같이 getters를 통해 이를 수행할 수 있습니다:

// 반려동물이 있는 모든 사용자 가져오기
export const getUsersWithPets = (state, getters) => {
  return state.users.filter(user => user.pet !== undefined);
};

컴포넌트에서 getter에 액세스하려면 mapGetters 헬퍼를 사용하세요:

import { mapGetters } from 'vuex';

{
  computed: {
    ...mapGetters([
      'getUsersWithPets',
    ]),
  },
};

mutation_types.js

Vuex mutations 문서에서:

여러 Flux 구현에서 변이 유형에 대해 상수를 사용하는 것이 일반적인 패턴입니다.

이렇게 하면 코드가 린터와 같은 도구의 이점을 활용할 수 있으며, 모든 상수를 단일 파일에 넣으면 협업자가 전체 애플리케이션에서 가능한 변이를 한눈에 볼 수 있습니다.

export const ADD_USER = 'ADD_USER';

스토어 상태 초기화

Vuex 스토어는 action을 사용할 수 있기 전에 초기 상태가 필요합니다.

이것은 종종 API 엔드포인트, 문서 URL 또는 ID와 같은 데이터를 포함합니다.

이 초기 상태를 설정하려면 Vue 컴포넌트를 마운트할 때 스토어 생성 함수에 매개변수로 전달하세요:

// Vue 앱의 초기화 스크립트에서 (예: 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)
  });
};

스토어 함수는 이 데이터를 상태 생성 함수에 전달할 수 있습니다:

// 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 객체에 포함할 수 있습니다:

// store/state.js에서

export default ({
  projectId,
  documentationPath,
  anOptionalProperty = true
}) => ({
  projectId,
  documentationPath,
  anOptionalProperty,

  // 여기 다른 상태 속성
});

초기 상태를 …spread하지 않는 이유는 무엇인가요?

날카로운 독자는 위의 예에서 몇 줄의 코드를 줄일 수 있는 기회를 봅니다:

// 이렇게 하지 마세요!

export default initialState => ({
  ...initialState,

  // 여기 다른 상태 속성
});

우리는 프론트엔드 코드베이스를 탐색하고 검색하는 능력을 향상시키기 위해 이 패턴을 피하기로 의식적으로 결정했습니다. 이는 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">
      로딩 ...
    </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',
    };

    // 스토어를 채웁니다
    await store.dispatch('addUser', user);

    expect(wrapper.text()).toContain(user.name);
  });
});

일부 테스트 파일은 여전히 더 이상 지원되지 않는 createLocalVue 함수를 사용할 수 있으며, localVue.use(Vuex)를 사용할 수 있습니다. 이는 불필요하며 가능한 한 피하거나 제거해야 합니다.

양방향 데이터 바인딩

Vuex에 양식 데이터를 저장할 때 저장된 값을 업데이트해야 하는 경우가 있습니다. 스토어는 직접 변형되어서는 안 되며 대신 액션을 사용해야 합니다.

v-model을 코드에서 사용하려면 다음과 같은 계산된 속성을 만들어야 합니다:

export default {
  computed: {
    someValue: {
      get() {
        return this.$store.state.someValue;
      },
      set(value) {
        this.$store.dispatch("setSomeValue", value);
      }
    }
  }
};

대안으로 mapStatemapActions를 사용할 수 있습니다:

export default {
  computed: {
    ...mapState(['someValue']),
    localSomeValue: {
      get() {
        return this.someValue;
      },
      set(value) {
        this.setSomeValue(value)
      }
    }
  },
  methods: {
    ...mapActions(['setSomeValue'])
  }
};

이러한 속성을 몇 개 추가하는 것은 번거롭고, 더 많은 테스트를 작성해야 하므로 코드를 더 반복적이게 만듭니다. 이를 단순화하기 위해 ~/vuex_shared/bindings.js에 도우미가 있습니다.

도우미는 다음과 같이 사용할 수 있습니다:

// 이 스토어는 기능이 없으며 예제의 맥락만 제공합니다
export default {
  state: {
    baz: '',
    bar: '',
    foo: ''
  },
  actions: {
    updateBar() {...},
    updateAll() {...},
  },
  getters: {
    getFoo() {...},
  }
}
import { mapComputed } from '~/vuex_shared/bindings'
export default {
  computed: {
    /**
     * @param {(string[]|Object[])} list - 상태 키와 일치하는 문자열 목록 또는 객체 목록
     * @param {string} list[].key - vuex 상태에 존재하는 키와 일치하는 키
     * @param {string} list[].getter - 게터의 이름, 게터를 사용하지 않으려면 비워 두십시오
     * @param {string} list[].updateFn - 액션의 이름, 기본 액션을 사용하려면 비워 두십시오
     * @param {string} defaultUpdateFn - 기본 함수를 배포합니다
     * @param {string|function} root - 목록에 설명된 키를 검색할 상태의 선택적 키
     * @returns {Object} 생성된 모든 계산된 속성이 포함된 사전
    */
    ...mapComputed(
      [
        'baz',
        { key: 'bar', updateFn: 'updateBar' },
        { key: 'foo', getter: 'getFoo' },
      ],
      'updateAll',
    ),
  }
}

mapComputed는 스토어에서 데이터를 가져오고 업데이트할 때 올바른 액션을 배포하는 적절한 계산된 속성을 생성합니다.

root 키의 루트가 여러 수준 깊은 경우 관련 상태 객체를 검색하는 데 함수를 사용할 수 있습니다.

예를 들어, 다음과 같은 스토어가 있다고 가정합니다:

// 이 스토어는 기능이 없으며 예제의 맥락만 제공합니다
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,
    ),
  }
}