리덕스의 원칙은 상태관리를 하는 스토어는 하나라는 것, 상태는 읽기 전용, 즉 상태에 변화를 가할 수 없다는 것, 순수함수인 리듀서를 통해 변화시킨다는 것입니다.
읽기 전용이지만, 리듀서를 통해 참조가 아예 다른 다른 상태를 반환한다는 점에서 다릅니다. 새로운 상태로 갱신이 되지만, 이전 상태에 덮어씌우기를 하지는 않는다는 뜻일 것입니다. 이 말 뜻은 곧, 이전 상태와의 비교가 가능하다는 뜻이기도 합니다. 실제로 리덕스 미들웨어인 logger를 통해 보면, 새로운 상태 및 이전상태를 쉽게 비교할 수 있습니다.
function createStore(reducer, preloadedState) {
let state = preloadedState
const listeners = []
function getState() {
return state
}
function subscribe(listener) {
listeners.push(listener)
return function unsubscribe() {
const index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
}
function dispatch(action) {
state = reducer(state, action)
listeners.forEach(listener => listener())
}
dispatch({ type: '@@redux/INIT' })
return { dispatch, subscribe, getState }
}
스토어는 리덕스 내장함수인 createStore를 통해 만들 수 있는데, 첫번째 인자로 reducer를 받고, 두번째 인자로 preloaded state를 받는데, 두번째 인자의 경우 로컬스토리지 또는 서버에서 받은 초기 데이터로서 리덕스에 넣을 수 있는 값으로서 필수값은 아닙니다.
먼저 리턴값을 보면 구조를 보기 쉬운데, store는 즉 dispatch, subscribe, getState라는 메서드로 이뤄진 객체입니다. 이 메소드들을 통해 클로저로 있는 state를 관리하게 됩니다 (let state = preloadedState (또는 null))
간단히 메소드를 보면,다음과 같습니다.
createStore에는 세번째 인자로 enhancer라는 인자를 하나 더 받을 수 있는데, 이것이 리덕스 미들웨어입니다. 물론 인자로 취하는 것 외에, 랩핑 후 createStore를 리턴하는 방법도 있습니다.
const enhancer = createStore => {
return createStore(reducer, initialState, enhancer);
}
const createStoreEnhanced = enhancer(createStore)
const enhancedStore = createStoreEnhanced(reducer, initialState)
// 또는 아래처럼 enhancer를 인자로 사용할 수도 있다.
const enhancedStore = createStore(reducer, initialState, enhancer)
미들웨어는 스토어 메소드를 대체하거나 보강하거나 하는데, 실제로는 dispatch 함수에 관한 사항이 많습니다. 예를 들면 dispatch logging을 하거나, try-catch 에러핸들링을 하기도 합니다. 마치 express에서 기능을 추가하기 위해 npm을 다운받아, 써드파티 미들웨어를 끼우는 것과 비슷한 것 같습니다.
미들웨어 구조는 아래와 같습니다.
const middleware = ({getState, dispatch}) => next => action => {
// do something...
return next(action);
}
위 구조가 나오게 된 계기는 아래와 같습니다. 예를 들어, 로깅을 하는 경우, 미들웨어 사용 전에는 액션 리듀싱하기 전 앞 뒤로 콘솔을 찍어야합니다.
const action = addTodo('Use Redux')
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
그러나, 디스패치 함수마다 콘솔로그를 찍어보는 것은 매우 번거로운 일입니다. 그래서 유틸함수로 빼서 처리하는 방법이 고안되었습니다.
function dispatchAndLog(store, action) {
console.log('dispatching', action);
store.dispatch(action);
console.log('next state', store.getStore());
}
이를 통해 dispatchAnd(store, addTodo('Use Redux'))와 같이 간결하게 사용할 수는 있게 되었습니다. 그러나, 매번 유틸을 import하기도 번거러운 일입니다. 이에, 아예 dispatch 함수를 바꿔버리자라는 생각으로 도달하게 됩니다 (이를 공식문서에서는 몽키패칭이라고합니다).
const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
그러나, 한 개 이상의 미들웨어를 꽂고 싶다면 어떻게 해야할까요? 예를 들면 에러핸들링하는 미들웨어 같은 것을 추가할 수 있을 것입니다. 이상적으로는 모듈 분리를 하는게 젤 좋지만, 이것 역시 쉽지는 않습니다.
function patchStoreToAddLogging(store) {
const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
function patchStoreToAddCrashReporting(store) {
const next = store.dispatch
store.dispatch = function dispatchAndReportErrors(action) {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}
}
몽키패칭을 하면서 로깅함수 다음에 에러핸들링 함수를 적용한다고 하면, 먼저 store.dispatch가 몽키패칭에 의해서 dispatchAndLog로 변합니다. 다음에 에러핸들링 함수를 적용할 때에는, dispatchAndReportErrors 함수까지 포함되는 dispatch 함수로 변하게 되는 것이죠. 즉 앞에서 봤던 enhancedStore가 되는 것입니다. 순조롭습니다. 그러나 몽키패칭은 함수를 변경시킨다는 점에서 API의 본래 취지에 어긋납니다. 그래서 나오게 된 게 새로운 dispatch함수를 리턴하는 방법입니다.
function logger(store) {
const next = store.dispatch
// Previously:
// store.dispatch = function dispatchAndLog(action) {
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
새로운 함수를 리턴하고, next 함수는 클로저에 의해 baseDispatch함수를 참조하게 되었습니다. 그럼 리턴되는 함수를 받아 enhancing을 도와주는 대상은 누구일까요?? 이 시점에서 helper함수가 도입되는데, 이는 아래와 같습니다.
function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
// Transform dispatch function with each middleware.
middlewares.forEach(middleware => (store.dispatch = middleware(store)))
}
//사용례: applyMiddlewareByMonkeypatching(store, [logger, crashReporter])
그러나 변한 것이 있나요? 여전히 store.dispatch에 enhanced dispatch를 할당하는 몽키패칭이 계속되고 있습니다.
function logger(store) {
// Must point to the function returned by the previous middleware:
const next = store.dispatch
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
그럼 store.dispatch를 몽키패칭(할당을 통한 변경)을 하지 않고도, 어떻게 체이닝을 할 수 있을까요??
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}
}
이를 위해, store 인스턴스에 읽는 것 대신 next라는 dispatch function을 인자로 받는 방법이 있습니다. 아래는 es6 버전의 화살표 함수를 사용한 미들웨어 구조입니다.
const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}
그래서 위의 미들웨어 구조를 적용하면, 다음과 같게 적용할 수 있습니다. 공식문서의 warning과 같이 이해를 돕기 위한 예시일 뿐입니다.
// Warning: Naïve implementation!
// That's *not* Redux API.
function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
let dispatch = store.dispatch
middlewares.forEach(middleware => (dispatch = middleware(store)(dispatch)))
return Object.assign({}, store, { dispatch })
}
스토어와 갱신되가는 dispatch 함수를 인자로 넘기면, store는 불변성을 유지하면서 (물론 여기서는 store.getState())만 사용하고 있지만..), 모든 미들웨어가 참조할 수 있으며, dispatch는 계속해서 enhance 되는 구조가 됩니다. 물론 위 구조는 실제 applyMiddleware와는 다르며, 실제로는 아래와 같습니다.
function applyMiddleware(...middlewares) {
// applyMiddleware는 기존 createStore의 고차 함수를 반환한다.
return (createStore) => (reducer, preloadedState, enhancer) => {
const store = createStore(reducer, preloadedState, enhancer)
let dispatch = store.dispatch
let chain = []
// 미들웨어들에게 인자로 전달되는 객체이다.
// dispatch가 단순히 store.dispatch의 참조를 전단하는 것이 아니라,
// 함수를 한번 더 감싸 사용하는 점을 기억하자.
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
// 미들웨어들이 반환하는 체인 함수들(= wrapDispatch)을 가져온다.
chain = middlewares.map(middleware => middleware(middlewareAPI))
// 미들웨어가 반환하는 체인 함수들을 중첩시킨 후 새로운 dispatch 함수를 만든다.
dispatch = compose(...chain)(store.dispatch)
// applyMiddleware를 통해 반환된 createStore 고차 함수는
// 기존 스토어와 동일한 API, 그리고 새로 만들어진 dispatch 함수를 반환한다.
return {
...store,
dispatch
}
}
}
위 예시에서는 ...store, dispatch로 이뤄진, 객체를 반환하나, 일반적으로는 createStore의 3번째 인자로 꽂아서 적용하기도 합니다.