본문으로 건너뛰기

Vuex

무료2017-07-01#JS#Vuex与Redux#Vuex与Flux#vuex组件状态共享#vuex解析#vuex实现机制

Flux, Redux를 거쳐 여기까지 도달했습니다.

1. 출발점

상대적으로 독립적인 컴포넌트에서는 action -> state -> view의 단방향 데이터 흐름이 보장됩니다. 하지만 실제 비즈니스 시나리오에서는 상태 전달 및 공유가 자주 필요하며, 일반적인 방법은 다음과 같습니다.

  • 상태 전달: 부모-자식 컴포넌트 간 통신은 props를 통해 이루어집니다 (순방향으로 속성값 전달, 역방향으로 메서드 전달). 형제 컴포넌트 간 통신은 이벤트를 사용하거나 상태를 부모 레벨로 끌어올려 (형제 통신 문제를 부모-자식 통신으로 변환) 해결해야 합니다.

  • 상태 공유: 한 컴포넌트에 두고 다른 컴포넌트에서 상태 참조를 가져오거나, 싱글톤으로 추출하여 여러 컴포넌트가 공유하게 합니다.

깊은 단계의 props 전달은 번거롭고, 형제 컴포넌트 간의 얽힌 이벤트 통신은 유지보수 문제를 야기하며, 상태를 부모로 끌어올리면 부모 컴포넌트가 비대해져 너무 많은 세부 상태를 관리하게 됩니다. 공유 상태를 한 컴포넌트에 두면 다른 컴포넌트에서 참조를 가져오기 까다롭고, 싱글톤으로 추출하는 것이 조금 낫지만 컴포넌트 트리 외부에 흩어진 공유 상태가 존재하게 되어 이 또한 유지보수 문제를 일으킬 수 있습니다.

상태 계층을 별도로 분리하면 상태 전달 및 공유 문제를 효과적으로 해결할 수 있습니다. 또한 action을 통해 상태 변경에 의미(시맨틱)를 부여함으로써 유지보수 문제를 완화할 뿐만 아니라 디버깅 측면에서도 이점을 얻을 수 있습니다.

2. 기본 원칙

  • 애플리케이션 레벨의 상태는 store에서 중앙 집중식으로 관리합니다.

  • 상태를 수정하는 유일한 방법은 동기 방식의 mutationcommit하는 것입니다.

  • 비동기 로직은 action에 둡니다.

관리가 용이한 단일 상태 트리와 상태 수정 방식의 규격화를 지향하며, 비즈니스 로직에 더 가깝게 설계되어 비동기 시나리오를 고려합니다.

3. 구조

Redux처럼 독특하지 않고 (reducer는 언뜻 보기에 Flux와 큰 상관이 없어 보일 수 있음), Vuex는 전형적인 Flux 구현체에 가깝습니다.

component 视图层 dispatch action
---
action 事件层 commit mutation
    异步 统一管理异步请求
---
mutation 响应层 mutate state
    同步 逻辑上原子级的状态修改
---
state 数据模型层 update model
    通过 数据绑定 映射到视图更新

여기서 mutationaction은 전역에서 공유되므로 컴포넌트 간 통신 문제도 해결됩니다 (상태를 수동으로 전달할 필요 없이 store에 무슨 일이 일어났는지 알리기만 하면 store가 무엇을 할지 압니다). 상태 끌어올리기나 전달을 피할 수 있으며 시맨틱한 이점도 제공합니다.

전역 공유로 인해 발생하는 이름 충돌 문제를 해결하기 위해 Vuex는 네임스페이스 옵션을 제공합니다.

Flux와의 비교

         产生action               传递action           update state
view交互 -----------> dispatcher -----------> stores --------------> views

가장 큰 차이점은 Vuex가 action을 비동기 시나리오를 위한 action과 동기 시나리오를 위한 mutation으로 세분화했다는 점입니다. 또한 store 자체가 dispatcher 역할을 수행하여 actionmutation의 등록 및 배분을 담당합니다.

즉, actionmutation을 하나의 계층(Flux의 action)으로 본다면 두 구조는 완전히 일치하므로, Vuex는 전형적인 Flux 구현체라고 할 수 있습니다.

store

state의 컨테이너이자 dispatcher 역할을 겸합니다.

store를 통한 state 관리는 역할상 global.share = {}와 유사하지만, Vuex의 store.state에는 몇 가지 특징이 있습니다.

  • state는 반응형 데이터입니다.

  • store가 보유한 state를 직접 수정하는 것은 허용되지 않으며, 반드시 명시적으로 mutationcommit해야 합니다.

컴포넌트의 data와 마찬가지로 store.state 역시 반응형입니다. 컴포넌트의 계산된 속성(computed)과 연결되어 state 업데이트가 view 계층으로 정확하게 전달됩니다.

store.state를 직접 수정하지 못하게 하는 것은 일종의 도덕적 제약입니다. strict 옵션을 켜면 에러가 발생하지만 실제로는 수정이 반영됩니다. 여기서 강력한 제약(쓰기 보호)을 두지 않은 것은 시장 상황을 고려한 선택일 수 있습니다.

추가로:

  • 단일 상태 트리: Redux와 마찬가지로 단일 트리를 사용하며, state를 분할하고 조직하기 위한 별도의 모듈화 메커니즘을 제공합니다.

  • 또한 모든 state를 Vuex에 넣을 필요는 없으며, 상대적으로 독립적인 상태는 컴포넌트 레벨에서 유지하는 것을 권장합니다.

getter

역할상 store의 계산된 속성에 해당합니다.

원래의 state를 가공하여 (store.state에 대해 filter, count, find 등의 간단한 계산 수행) 뷰에 표시하기 적합한 형태로 래핑합니다.

getter가 없다면 이러한 가벼운 로직들은 computed나 템플릿에 두어야 하겠지만, getter를 제공함으로써 state와 관련된 모든 것을 외부로 분리할 수 있습니다.

mutation

state 업데이트를 담당하며, 모든 mutation동기 작업입니다. commit mutation 호출 바로 다음 줄에서 state 업데이트가 완료됩니다.

store에 미리 등록되어 있으며, commit 시마다 mutation 테이블을 조회하여 대응하는 state 업데이트 함수를 실행합니다.

주의: mutation반드시 동기적이어야 합니다. 그렇지 않으면 디버깅 도구가 정확한 상태 스냅샷을 캡처할 수 없어 (비동기로 상태를 수정할 경우) 상태 추적이 망가집니다.

action

비동기 시나리오에 대응하기 위한 수단으로, mutation을 보완합니다.

Vuex는 Flux의 action을 동기와 비동기에 따라 mutationaction으로 나눈 것과 같습니다.

actionmutation처럼 state를 직접 수정하지 않고, commit mutation을 통해 간접적으로 수정합니다. 즉, 오직 mutation만이 원자 단위의 상태 업데이트 작업에 대응합니다.

action 내부에서는 비동기 작업을 수행할 수 있습니다. 설계상 의도적으로 비동기 처리를 위한 action과 동기 처리를 위한 mutation을 분리했습니다.

비동기 흐름 제어

비동기 흐름 제어는 actionpromise를 반환하게 함으로써 콜백 함수를 전달하는 것보다 우아하게 해결할 수 있습니다.

Vuex v2.x (2017/7/1 기준 최신 v2.3.0)의 store.dispatch는 기본적으로 promise를 반환하며, promise가 아닌 action의 반환값은 Promise.resolve()에 의해 promise로 래핑됩니다.

dispatch(type: string, payload?: any, options?: Object) | dispatch(action: Object, options?: Object)

Dispatch an action. options can have root: true that allows to dispatch root actions in namespaced modules. Returns a Promise that resolves all triggered action handlers.

(API 레퍼런스에서 발췌)

하지만 비동기 작업에 대해서는 의미가 없으므로 (Promise.resolve(undefined)), 비동기 흐름을 제어해야 한다면 여전히 수동으로 promise를 반환하고 내부 promise에서 필요한 정보를 전달해야 합니다.

module

store를 분할하고 조직하기 위한 모듈화 메커니즘입니다.

namespaced 옵션을 제공하여 등록 시 모듈 경로를 접두사로 사용합니다. 모듈 내부에 local.dispatch/commit/getters/state를 주입하여 네임스페이스의 영향을 상쇄하는 정교한 설계 덕분에, 모듈 내부에서는 네임스페이스를 붙일 필요가 없고 외부(비즈니스 로직 또는 다른 모듈)에서만 네임스페이스를 사용하면 됩니다. 이로써 네임스페이스는 store 구조에 영향을 주지 않는 스위치 옵션이 됩니다.

4. 도구

마찬가지로 Vuex도 state -> view 부분을 처리해야 합니다 (역할상 react-redux와 유사하게 상태 관리 계층을 뷰 계층에 연결함).

정교한 데이터 바인딩을 지원하는 Vue는 React처럼 번거로운 과정(가상 DOM 트리에 container를 삽입하여 store.state의 변화를 전파하는 등)이 필요 없습니다. 단지 store.state와 컴포넌트 상태를 연결하기만 하면 됩니다. 마치 소프트 링크처럼 컴포넌트와 storestate 객체를 공유하며, state의 변화가 반응형 특징을 통해 컴포넌트로 전달되어 뷰가 업데이트됩니다.

mapState

store.state와 컴포넌트의 computed를 연결합니다.

주의: mapState는 컴포넌트에서 computed를 직접 수정하여 실제 상태에 영향을 주는 것을 강제로 금지합니다 (mapState로 생성된 계산된 속성은 읽기 전용입니다).

{
    configurable: true,
    enumerable: true,
    get: function computedGetter(),
    set: function noop()
}

mapGetters

store.getter와 컴포넌트의 computed를 연결합니다.

mapState와 마찬가지로 쓰기 보호 기능이 있습니다.

mapMutations

mutation과 컴포넌트의 methods를 연결합니다.

컴포넌트에서 mutationcommit하는 과정을 단순화합니다 (최상위에서 store 주입 필요).

mapActions

action과 컴포넌트의 methods를 연결합니다.

actiondispatch하는 과정을 단순화합니다 (마찬가지로 store 주입 필요).

5. 궁금한 점

1. 동일한 컴포넌트 간의 상태 공유를 어떻게 방지하나요?

예를 들어 리스트에 3개의 동일한 컴포넌트가 있을 때, state 공유로 인해 발생하는 상태 동기화 문제를 어떻게 피할 수 있을까요?

모듈 재사용과 상태 공유의 충돌 문제입니다. data를 처리할 때처럼 객체 형태의 state 대신 함수 형태의 state를 사용하여 새로운 상태 객체를 반환하게 합니다. 이렇게 하면 3개 컴포넌트에 대응하는 state(store.state의 일부분)가 각각 독립적이며 별도의 상태 관리가 필요하지 않습니다.

주의: 함수 형태의 state 기능은 Vuex v2.3.0 이상에서 사용할 수 있습니다. 하위 버전에서는 다음과 같은 다른 방법을 고려해야 합니다.

  • state 레벨을 한 단계 높이기 (배열을 유지하여 state list 관리)

  • 공유할 수 없는 로컬 상태는 컴포넌트 레벨에 두고, 공유 가능한 데이터와 작업만 store에 두는 것을 고려하기

첫 번째 방식은 store를 급격히 비대하게 만들고, action/mutation 등에서 index가 필요하게 되어 컴포넌트가 index를 다시 store로 전달해야 하는 번거로움이 있어 권장하지 않습니다.

두 번째 방식이 근본적인 해결책입니다. state를 나누는 기법은 어디서나 통용되며, 단순히 모든 것을 'Vuex화'하기 위해 Vuex를 사용해서는 안 됩니다. 모든 상태를 컴포넌트에서 분리하여 store에 넣는 것이 불가능한 것은 아니지만, store가 보유한 상태가 너무 세밀하면 개발과 유지보수에 큰 지장이 생깁니다.

  • 개발 시 컴포넌트 내부의 아주 작은 변화조차 dispatch/commit을 거쳐야 합니다.

  • 유지보수 시 수천 개의 mutation type을 가진 매우 복잡한 store를 상대해야 합니다.

이러한 번거로움은 자초한 결과입니다. 상태를 어떻게 나누어야 할지 고려해 봅시다.

  • 상호작용 관련 UI 상태는 컴포넌트 레벨에 둡니다. 예: 펼치기/접기, loading 표시/숨김, tab/테이블 페이지네이션 등

  • 공유할 수 없는 데이터 상태는 컴포넌트 레벨에 둡니다. 예: 폼 입력 데이터

  • 공유 가능한 데이터 상태는 상태 계층에 둡니다. 예: 캐시 가능한 서비스 데이터

store의 역할은 프론트엔드 데이터 계층으로서 server + database와 같아야 하며, 단순히 애플리케이션 상태를 컴포넌트 트리에서 분리해 상태 트리로 만드는 것은 큰 의미가 없습니다.

2. computed 속성과 Vuex의 store.state는 어떻게 연결되나요?

런타임 의존성 수집 메커니즘

// 组件
computed: {
    user() {
        return this.$store.state.user;
    }
}

// store
mutations: {
    [types.SET_USER] (state, user) {
        state.user = user;
    }
}

computed 속성을 계산할 때 user() 실행 과정에서 store.state.user에 접근하게 되고, 이때 stategetter가 트리거되어 user() 함수가 store.state.user에 의존한다는 정보를 기록합니다. 이후 commit mutation으로 store.state.user를 수정하면 statesetter가 트리거되어 user 속성에 대응하는 모든 의존성 항목(user() 함수 포함)에 대해 다시 값을 계산합니다. 이어서 computedsetter가 트리거되어 computed.user에 대응하는 모든 의존성 항목(뷰 업데이트 함수 포함)을 실행하고 뷰 업데이트가 완료됩니다.

P.S. 의존성 수집 메커니즘의 구체적인 구현은 vue/src/core/observer/dep.js를 참조하세요.

3. store 전달 메커니즘

react-redux의 Provider와 유사하게, 한 번의 주입으로 전역에서 사용할 수 있는 방식을 제공합니다 (Vue.use(Vuex)를 호출하고 최상위 컴포넌트 생성 시 store를 전달함). Vuex는 플러그인으로서 Vue.prototype을 수정하여 $store를 등록함으로써 모든 vm(Vue 인스턴스)이 공유할 수 있게 합니다.

4. input 등의 양방향 바인딩 시나리오와 store.state 직접 수정 불가 원칙 간의 충돌

계산된 속성(computed)의 getter/setter를 통해 처리합니다.

  • getter에서 store.state를 읽습니다.

  • setter에서 mutationcommit하여 store.state를 씁니다.

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성