Skip to content

Commit

Permalink
~ch20
Browse files Browse the repository at this point in the history
  • Loading branch information
kumass2020 committed Jan 16, 2023
1 parent 6b939aa commit 4f2e5a6
Show file tree
Hide file tree
Showing 19 changed files with 407 additions and 37 deletions.
2 changes: 2 additions & 0 deletions ssr-recipe/config/webpack.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const LoadablePlugin = require('@loadable/webpack-plugin');
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
Expand Down Expand Up @@ -563,6 +564,7 @@ module.exports = function (webpackEnv) {
].filter(Boolean),
},
plugins: [
new LoadablePlugin(),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
Expand Down
1 change: 1 addition & 0 deletions ssr-recipe/dist/js/pages-BluePage.chunk.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ssr-recipe/dist/js/pages-RedPage.chunk.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ssr-recipe/dist/js/pages-UsersPage.chunk.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion ssr-recipe/dist/server.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions ssr-recipe/dist/server.js.LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
8 changes: 8 additions & 0 deletions ssr-recipe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"private": true,
"dependencies": {
"@babel/core": "^7.16.0",
"@loadable/babel-plugin": "^5.13.2",
"@loadable/component": "^5.15.2",
"@loadable/server": "^5.15.2",
"@loadable/webpack-plugin": "^5.15.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
"@svgr/webpack": "^5.5.0",
"@testing-library/jest-dom": "^5.14.1",
Expand Down Expand Up @@ -48,6 +52,7 @@
"react-refresh": "^0.11.0",
"react-router-dom": "5",
"redux": "^4.2.0",
"redux-saga": "^1.2.2",
"redux-thunk": "^2.4.2",
"resolve": "^1.20.0",
"resolve-url-loader": "^4.0.0",
Expand Down Expand Up @@ -143,6 +148,9 @@
"babel": {
"presets": [
"react-app"
],
"plugins": [
"@loadable/babel-plugin"
]
},
"devDependencies": {
Expand Down
7 changes: 5 additions & 2 deletions ssr-recipe/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import logo from './logo.svg';
import './App.css';
import { Route } from 'react-router-dom';
import Menu from './components/Menu';
import RedPage from './pages/RedPage';
import BluePage from './pages/BluePage';
import loadable from '@loadable/component';
const RedPage = loadable(() => import('./pages/RedPage'));
const BluePage = loadable(() => import('./pages/BluePage'));
const UsersPage = loadable(() => import('./pages/UsersPage'));

function App() {
return (
Expand All @@ -12,6 +14,7 @@ function App() {
<hr />
<Route path="/red" component={RedPage} />
<Route path="/blue" component={BluePage} />
<Route path="/users" component={UsersPage} />
</div>
);
}
Expand Down
3 changes: 3 additions & 0 deletions ssr-recipe/src/components/Menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const Menu = () => {
<li>
<Link to="/blue">Blue</Link>
</li>
<li>
<Link to="/users">Users</Link>
</li>
</ul>
);
};
Expand Down
17 changes: 17 additions & 0 deletions ssr-recipe/src/components/User.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';

const User = ({ user }) => {
const { email, name, username } = user;
return (
<div>
<h1>
{username} ({name})
</h1>
<p>
<b>e-mail:</b> {email}
</p>
</div>
);
};

export default User;
25 changes: 25 additions & 0 deletions ssr-recipe/src/containers/UserContainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, {useEffect} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import User from '../components/User';
import { usePreloader } from '../lib/PreloadContext';
import { getUser } from '../modules/users';

const UserContainer = ({ id }) => {
const user = useSelector(state => state.users.user);
const dispatch = useDispatch();

usePreloader(() => dispatch(getUser(id))); // 서버 사이드 렌더링을 할 때 API 호출하기
useEffect(() => {
if (user && user.id === parseInt(id, 10)) return; // 사용자가 존재하고, id가 일치한다면 요청하지 않음
dispatch(getUser(id));
}, [dispatch, id, user]); // id가 바뀔 떄 새로 요청해야 함

// 컨테이너 유효성 검사 후 return null을 해야 하는 경우에
// null 대신 Preloader 반환
if (!user) {
return null;
}
return <User user={user} />;
};

export default UserContainer;
30 changes: 30 additions & 0 deletions ssr-recipe/src/containers/UsersContainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { useEffect } from 'react';
import Users from '../components/Users';
import { connect } from 'react-redux';
import { getUsers } from '../modules/users';
import { Preloader } from '../lib/PreloadContext';

// const { useEffect } = React;

const UsersContainer = ({ users, getUsers }) => {
// 컴포넌트가 마운트되고나서 호출
useEffect(() => {
if (users) return; // users가 이미 유효하다면 요청하지 않음
getUsers();
}, [getUsers, users]);
return (
<>
<Users users={users} />
<Preloader resolve={getUsers}/>
</>
);
};

export default connect(
state => ({
users: state.users.users
}),
{
getUsers
}
)(UsersContainer);
52 changes: 42 additions & 10 deletions ssr-recipe/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,52 @@ import { BrowserRouter } from 'react-router-dom';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';
import createSagaMiddleware from 'redux-saga';
import rootReducer, { rootSaga } from './modules';
import { loadableReady } from '@loadable/component';

const store = createStore(rootReducer, applyMiddleware(thunk));
const sagaMiddleware = createSagaMiddleware();

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
const store = createStore(
rootReducer,
window.__PRELOADED_STATE__,
applyMiddleware(thunk, sagaMiddleware)
);

sagaMiddleware.run(rootSaga);

// 같은 내용을 쉽게 재사용할 수 있도록 렌더링할 내용을 하나의 컴포넌트로 묶음
const Root = () => {
return (
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
};

const root = document.getElementById('root');

// 프로덕션 환경에서는 loadableReady와 hydrate를 사용하고
// 개발 환경에서는 기존 방식으로 처리
if (process.env.NODE_ENV === 'production') {
loadableReady(() => {
ReactDOM.hydrate(<Root />, root);
});
} else {
ReactDOM.render(<Root />, root);
}

// const root = ReactDOM.createRoot(document.getElementById('root'));
// root.render(
// <Provider store={store}>
// <BrowserRouter>
// <App />
// </BrowserRouter>
// </Provider>
// );

// ReactDOM.render(
// <BrowserRouter>
// <App />
Expand All @@ -30,4 +63,3 @@ root.render(
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
84 changes: 66 additions & 18 deletions ssr-recipe/src/index.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@ import { StaticRouter } from 'react-router-dom';
import App from './App';
import path from 'path';
import fs from 'fs';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga'
import rootReducer, {rootSaga} from './modules';
import PreloadContext from './lib/PreloadContext';
import { END } from 'redux-saga';
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';

// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve('./build/asset-manifest.json'), 'utf8')
);
const statsFile = path.resolve('./build/loadable-stats.json');

const chunks = Object.keys(manifest.files)
.filter(key => /chunk\.js$/.exec(key)) // chunk.js로 끝나는 키를 찾아서
.map(key => `<script src="${manifest.files[key]}"></script>`) // 스크립트 태그로 변환하고
.join(''); // 합침
// const chunks = Object.keys(manifest.files)
// .filter(key => /chunk\.js$/.exec(key)) // chunk.js로 끝나는 키를 찾아서
// .map(key => `<script src="${manifest.files[key]}"></script>`) // 스크립트 태그로 변환하고
// .join(''); // 합침

function createPage(root) {
function createPage(root, tags) {
return `<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -28,33 +33,76 @@ function createPage(root) {
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
<link href="${manifest.files['main.css']}" rel="stylesheet" />
${tags.styles}
${tags.links}
</head>
<body>
<noscript>You need to enable Javascript to run this app.</noscript>
<div id="root">
${root}
</div>
<script src="${manifest.files['runtime-main.js']}"></script>
${chunks}
<script src="${manifest.files['main.js']}"></script>
${tags.scripts}
</body>
</html>
`;
}
const app = express();

// 서버 사이드 렌더링을 처리할 핸들러 함수
const serverRender = (req, res, next) => {
const serverRender = async (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버 사이드 렌더링
const context = {};
const sagaMiddleware = createSagaMiddleware();

const store = createStore(
rootReducer,
applyMiddleware(thunk, sagaMiddleware)
);

const sagaPromise = sagaMiddleware.run(rootSaga).toPromise();

const preloadContext = {
done: false,
promises: []
};

// 필요한 파일을 추출하기 위한 ChunkExtractor
const extractor = new ChunkExtractor({ statsFile });

const jsx = (
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
<ChunkExtractorManager extractor={extractor}>
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
</ChunkExtractorManager>
);

ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup으로 한번 렌더링
store.dispatch(END); // redux-saga의 END 액션을 발생시키면 액션을 모니터링하는 사가들이 모두 종료
try {
await sagaPromise; // 기존에 진행 중이던 사가들이 모두 끝날 때까지 기다림
await Promise.all(preloadContext.promises); // 모든 프로미스를 기다림.
} catch (e) {
return res.status(500);
}
preloadContext.done = true;
const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
res.send(createPage(root)); // 결과물 응답
// JSON을 문자열로 변환하고 악성 스크립트가 실행되는 것을 방지하기 위해 <를 치환 처리
const stateString = JSON.stringify(store.getState()).replace(/</g, '\\u003c');
const stateScript = `<script>__PRELOADED_STATE__ = ${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입

// 미리 불러와야 하는 스타일/스크립트를 추출하고
const tags = {
scripts: stateScript + extractor.getScriptTags(), // 스크립트 앞부분에 리덕스 상태 넣기
links: extractor.getLinkTags(),
styles: extractor.getStyleTags()
};

res.send(createPage(root, tags)); // 결과물 응답
};

const serve = express.static(path.resolve('./build'), {
Expand Down
27 changes: 27 additions & 0 deletions ssr-recipe/src/lib/PreloadContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createContext, useContext } from 'react';

// 클라이언트 환경: null
// 서버 환경: { done: false, promises: [] }
const PreloadContext = createContext(null);
export default PreloadContext;

// resolve는 함수 타입.
export const Preloader = ({ resolve }) => {
const preloadContext = useContext(PreloadContext);
if (!preloadContext) return null; // context 값이 유효하지 않다면 아무것도 하지 않음
if (preloadContext.done) return null; // 이미 작업이 끝났다면 아무것도 하지 않음

// promises 배열에 프로미스 등록
// resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위해
// Promise.resolve 함수 사용
preloadContext.promises.push(Promise.resolve(resolve()));
return null;
};

// Hook 형태로 사용할 수 있는 함수
export const usePreloader = resolve => {
const preloadContext = useContext(PreloadContext);
if (!preloadContext) return null;
if (preloadContext.done) return null;
preloadContext.promises.push(Promise.resolve(resolve()));
}
7 changes: 6 additions & 1 deletion ssr-recipe/src/modules/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { combineReducers } from "redux";
import users from './users';
import users, { usersSaga } from './users';
import { all } from 'redux-saga/effects'

export function* rootSaga() {
yield all([usersSaga()]);
}

const rootReducer = combineReducers({ users });
export default rootReducer;
Loading

0 comments on commit 4f2e5a6

Please sign in to comment.