December 07, 2019
이 튜토리얼에 대한 전체 코드는 여기에서 보실 수 있습니다.
프로젝트를 만들면서 해결한 버그는 이 곳에 정리되어 있습니다. ✈️ SSR
라벨의 이슈를 참고해주세요.
이번 포스트에서 할 일은 다음과 같습니다.
client에서 수행하던 행동에 대한 정의가 server에게 공유되어야하기 때문에 기존에 route를 정의하는 방식을 변경하겠습니다. 먼저, 아래와 같이 RouteBranch
인터페이스를 선언합니다.
export interface RouteBranch {
path: string;
exact?: boolean;
component: React.ComponentType<any>;
loadaData?: (params: any) => any;
routes: Array<RouteBranch>;
}
route를 구성하는데에 필요한 요소들(path, component 등)을 정의하고 이 route에서 수행해야하는 작업을 loadData라는 함수로 명시했습니다.
추후 이 함수를 통해 server에서 비즈니스 로직을 수행합니다.
이제 이 프로젝트의 route
를 모두 명시해줍니다.
const UserView = loadable(
() => import(/* webpackChunkName: "user" */ '../User'), { ssr: false }
);
const OrgView = loadable(
() => import(/* webpackChunkName: "org" */'../Org')
);
export const routes: Array<RouteBranch> = [
{
path: '/user',
component: UserView,
},
{
path: '/org',
component: OrgView,
loadData: (store: Store) => {
store.dispatch(
orgGitHub.fetch({
// fetch Data
})
)
}
},
];
/user
route는 SSR로 렌더링 하지 않을것이고, 당연히 server에서 DataFetch할 작업도 없기때문에 loadData를 적어주지 않았습니다.
/org
route 설정을 살펴보면 store를 받아서 action을 dispatch 합니다.
이 프로젝트에서 모든 비즈니스 로직은 action dispatch -> middleware -> store로 처리하고 있기 때문에 같은 방식으로 맞춰주었습니다.
이 store는 server로부터 전달받게 됩니다.
server에서는 다음과 같은 작업을 수행할 것입니다.
이 작업들은 handleRenderer
에서 진행해보겠습니다.
const handleRender =(req, res) => {
try {
const appStore = getStore();
loadBranchData(req.url)(appStore).then((data) => {
if (data.every((data) => data === null)) {
const config = getHtmlConfigs(req, appStore);
const { html, webExtractor } = config;
res.send(renderFullPage(webExtractor,html, {}));
return;
}
});
} catch(e) {
// error handling
}
}
app.get('*', handleRender);
handleRenderer에서는 2가지 케이스를 다룹니다.
이 경우 rendering만 완료해서 client에 넘겨주면 됩니다. 비즈니스 로직이 없는 경우는, if (data.every((data) => data === null)
구문을 통해 검사하게 됩니다.
loadData
에서 수행할 작업이 없을시에 null을 반환하게 했습니다. 그러므로, loadData에서
결과가 모두 null일때는 수행할 작업이 없는 것으로 판단하고 res.send
를 통해 html을 반환합니다.
const handleRender = (req, res) => {
try {
const appStore = getStore();
loadBranchData(req.url)(appStore).then((data) => {
if (data.every((data) => data === null)) {
// 1번의 경우
}
// 2번
const unsubscribe = appStore.subscribe(() => {
const config = getHtmlConfigs(req, appStore);
const finalState = appStore.getState();
const loadingKeys = Object.keys(finalState.loading);
const { html, webExtractor } = config;
const fetchState = new Array(loadingKeys.length).fill(false);
loadingKeys.forEach((key: string, index: number) => {
const isFetched = finalState.loading[key] !== HttpStatusCode.LOADING;
fetchState[index] = isFetched;
});
const isAllFetched = fetchState.every((state) => state);
if (isAllFetched) {
unsubscribe();
res.send(renderFullPage(webExtractor, html, finalState));
return;
}
});
});
} catch(e) {
// error handling
}
};
이 코드를 몇 가지 단계로 설명해보면 다음과 같습니다.
HttpStatusCode.LOADING
가 아닐경우 true를 저장합니다.unsubscribe
해주고 html을 반환합니다.이 프로젝트에서는 loading reducer를 따로 두어서 위와같이 코드를 작성했습니다. 구조에 맞게 fetchState를 검사하는 로직을 추가하면 됩니다.
loadBranchData
라는 함수는 어떤 역할을 하고 있을까요?
// server/util.ts
import { MatchedRoute, matchRoutes } from 'react-router-config';
import { applyMiddleware, compose, createStore, Store } from "redux";
import root from 'window-or-global';
import { routes } from '@/routes/controller';
import epics from '@/store/epics';
import reducers from '@/store/reducers';
export const loadBranchData = (pathname: string) => (store: Store) => {
// Get exact MatchedRoute.
const branch: Array<MatchedRoute<any>> = matchRoutes(routes, pathname);
const promises = branch.map(({ route }) => (
route.loadData ? route.loadData(store) : Promise.resolve(null)
));
return Promise.all(promises);
};
이 함수는 pathname과 store를 파라미터로 받습니다.
matchRoutes
의 도움을 받습니다.필요한 일들을 Promise.all()을 이용해 처리합니다.
// server/util.ts
export const getStore = () => {
const epicMiddleware = createEpicMiddleware();
const composeEnhancers = (root as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const appStore = createStore(reducers, composeEnhancers(applyMiddleware(epicMiddleware)));
epicMiddleware.run(epics);
return appStore;
};
기존에 client에서 store를 만들때 작성해준 코드를 동일하게 작성해줍니다.
🍿(스포): client에서 store를 만드는 함수가 수정됩니다.
현재는 server에서 client에 명시된 작업을 수행하고 결과를 만드는 것까지만 수행했습니다. 이제 이 결과를 client에 넘겨주어야 합니다.
Redux의 Server Rendering 문서를 참고해서 작성해보겠습니다. server에서 만든 state를 window
변수에 담아 전달하게 됩니다.
export const renderFullPage = (
webExtractor: ChunkExtractor,
html: string,
preloadedState: RootStoreState | {},
) => {
return(`
<!DOCTYPE html>
<html lang="ko">
<head>
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="google" content="notranslate">
<title>soso template server</title>
${webExtractor.getLinkTags()}
${webExtractor.getStyleTags()}
</head>
<body>
<div id="root">${html}</div>
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g,'\\u003c')}
</script>
${webExtractor.getScriptTags()}
</body>
</html>
`)
}
window.__PRELOADED_STATE__
에 server에 의해 생성된 초기 state를 저장합니다.
이 값을 이용해서 client에서 store를 만드는 코드를 살펴보겠습니다.
import root from 'window-or-global';
export default (() => {
const preloadedState = root.__PRELOADED_STATE__;
delete root.__PRELOADED_STATE__;
const epicMiddleware = createEpicMiddleware();
const composeEnhancers = (root as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
reducers, preloadedState, composeEnhancers(applyMiddleware(epicMiddleware))
);
epicMiddleware.run(epics);
return store;
})();
window is not defined를 해결하기 위해
window-or-global
를 사용해, window대신root
를 사용했습니다.
root(window)변수를 참조해서 server에서 미리 정의한 state를 가져오고 store를 만들때 같이 넘겨줍니다. 이 과정을 통해, server에서 미리 만든 state로 초기화가 가능해졌습니다.
필요한 작업은 모두 끝났습니다.이제 확인해볼까요?
이 글에는 full code를 담지 않았으니 이 PR과 함께 보시는 것을 추천드립니다.
npm start
터미널에 위 명령을 입력하고 /org
페이지로 접근해봅시다.
기존에는 Header영역과 Loading영역만 내려왔다면, 이제는 Full Contents가 채워진 document가 내려오게 됩니다.
특정 url요청 시 로더 없이 바로 컨텐츠가 그려진 html이 내려오게 되는 것입니다.
이 단계까지 수행하고 나면, ‘로더 없이 바로 그려지는 상황이 과연 좋은 UX인가’에 대한 의문이 들 수 있습니다.
그래서, 이 튜토리얼의 마지막 글에서는 UX관점에서의 SSR에 대한 정리를 해보겠습니다.