티스토리 뷰
서버 사이드 렌더링을 구현하려면 웹팩 설정을 커스터마이징해 주어야 한다.
git add .
git commit -m 'Commit before eject'
yarn eject
서버 사이드 렌더링용 엔트리 만들기
엔트리(entry)는 웹팩에서 프로젝트를 불러올 때 가장 먼저 불러오는 파일입니다. 예를 들어 현재 작성 중인 리액트 프로젝트에서는 index.js를 엔트리 파일로 사용한다. 이 파일부터 시작하여 내부에 필요한 다른 컴포넌트와 모듈을 불러오고 있다.
index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
const html = ReactDOMServer.renderToString(
<div>Hello Server Side Rendering!</div>
);
console.log(html);
서버 사이드 렌더링 전용 웹팩 환경 설정 작성하기
config/path.js
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp('build'),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
appJsConfig: resolveApp('jsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
appNodeModules: resolveApp('node_modules'),
swSrc: resolveModule(resolveApp, 'src/service-worker'),
/////////////////////////////////////////////////////////////////요기
ssrIndexJs: resolveApp('src/index.server.js'), // 서버 사이드 렌더링 엔트리
ssrBuild:resolveApp('dist'), // 웹팩 처리 후 저장 경로
////////////////////////////////////////////////////////////////요기
publicUrlOrPath,
};
ssrIndexJs는 불러올 파일의 경로
ssrBuild는 웹팩으로 처리한 뒤 결과물을 저장할 경로
config/webpack.config.server.js
const paths = require("./paths");
module.exports = {
mode: "production", // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
entry: paths.ssrIndexJs, // 엔트리 경로
target: "node", // node 환경에서 실행될 것이라는 점을 명시
output: {
path: paths.ssrBuild, // 빌드 경로
filename: "server.js", // 파일 이름
chunkFilename: "js[name].chunk.js", // 청크 파일 이름
publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
},
};
웹팩 기본 설정을 작성했습니다. 빌드할 때 어떤 파일에서 시작해 파일들을 불러오는지, 또 어디에 결과물을 저장할지를 정해 주었죠.
다음으로 로더를 설정합니다. 웹팩의 로더는 파일을 불러올 때 확장자에 맞게 필요한 처리를 해준다. 예를 들어 자바스크립트는 babel을 사용하여 트랜스파일링을 해 주고, CSS는 모든 CSS 코드를 결합해 주고, 이미지 파일을 다른 경로에 따로 저장하고 그 파일에 대한 경로를 자바스크립트에서 참조할 수 있게 해 준다.
서버 사이드 렌더링을 할 때 CSS 혹은 이미지 파일은 그다지 중요하지 않습니다.그렇다고 완전히 무시할 수는 없습니다. 가끔 자바스크립트 내부에서 파일에 대한 경로가 필요하거나 CSS Module처럼 로컬 className을 참조해야 할 수도 있기 때문입니다. 그래서 해당 파일을 로더에서 별도로 설정하여 처리하지만 따로 결과물에 포함되지 않도록 구현할 수 있습니다.
const paths = require("./paths");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent");
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
module.exports = {
mode: "production", // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
entry: paths.ssrIndexJs, // 엔트리 경로
target: "node", // node 환경에서 실행될 것이라는 점을 명시
output: {
path: paths.ssrBuild, // 빌드 경로
filename: "server.js", // 파일 이름
chunkFilename: "js[name].chunk.js", // 청크 파일 이름
publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
},
module: {
rules: [
{
oneOf: [
// 자바스크립트를 위한 처리
// 기존 webpack.config.js 를 참고하여 작성
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve("babel-loader"),
options: {
customize: require.resolve(
"babel-preset-react-app/webpack-overrides"
),
presets: [
[
require.resolve("babel-preset-react-app"),
{
runtime: "automatic",
},
],
],
plugins: [
[
require.resolve("babel-plugin-named-asset-import"),
{
loaderMap: {
svg: {
ReactComponent:
"@svgr/webpack?-svgo,+titleProp,+ref![path]",
},
},
},
],
],
cacheDirectory: true,
cacheCompression: false,
compact: false,
},
},
// CSS 를 위한 처리
{
test: cssRegex,
exclude: cssModuleRegex,
// exportOnlyLocals: true 옵션을 설정해야 실제 css 파일을 생성하지 않습니다.
loader: require.resolve("css-loader"),
options: {
importLoaders: 1,
modules: {
exportOnlyLocals: true,
},
},
},
// CSS Module 을 위한 처리
{
test: cssModuleRegex,
loader: require.resolve("css-loader"),
options: {
importLoaders: 1,
modules: {
exportOnlyLocals: true,
getLocalIdent: getCSSModuleLocalIdent,
},
},
},
// Sass 를 위한 처리
{
test: sassRegex,
exclude: sassModuleRegex,
use: [
{
loader: require.resolve("css-loader"),
options: {
importLoaders: 3,
modules: {
exportOnlyLocals: true,
},
},
},
require.resolve("sass-loader"),
],
},
// Sass + CSS Module 을 위한 처리
{
test: sassRegex,
exclude: sassModuleRegex,
use: [
{
loader: require.resolve("css-loader"),
options: {
importLoaders: 3,
modules: {
exportOnlyLocals: true,
getLocalIdent: getCSSModuleLocalIdent,
},
},
},
require.resolve("sass-loader"),
],
},
// url-loader 를 위한 설정
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
emitFile: false, // 파일을 따로 저장하지 않는 옵션
limit: 10000, // 원래는 9.76KB가 넘어가면 파일로 저장하는데
// emitFile 값이 false 일땐 경로만 준비하고 파일은 저장하지 않습니다.
name: "static/media/[name].[hash:8].[ext]",
},
},
// 위에서 설정된 확장자를 제외한 파일들은
// file-loader 를 사용합니다.
{
loader: require.resolve("file-loader"),
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
emitFile: false, // 파일을 따로 저장하지 않는 옵션
name: "static/media/[name].[hash:8].[ext]",
},
},
],
},
],
}
};
이제 코드에서 node_modules 내부의 라이브러리를 불러올 수 있게 설정합니다.
module: {
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
},
resolve: {
modules: ['node_modules'],
},
서버에서는 굳이 결과물 파일 안에 리액트 라이브러리가 들어 있지 않아도 됩니다. node_modules를 통해 바로 불러와서 사용할 수 있기 때문이죠. 따라서 서버를 위해 번들링 할 때는 node_modules에서 불러오는 것을 제외하고 번들링하는 것이 좋습니다. 이를 위해 webpack-node-externals라는 라이브러리를 사용해야 합니다.
yarn add webpack-node-externals
const nodeExternals = require('webpack-node-externals');
~~~~~~~~~~~~
resolve: {
modules: ["node_modules"],
},
externals: [nodeExternals()],
마지막으로 환경변수를 주입하겠습니다.
const webpack = require("webpack");
const getClientEnvironment = require("./env");
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
externals: [nodeExternals()],
plugins: [
new webpack.DefinePlugin(env.stringified), // 환경변수를 주입해줍니다.
],
환경변수를 주입하면, 프로젝트 내에서 process.env.NODE_ENV 값을 참조하여 현재 개발 환경인지 아닌지를 알 수 있습니다.
최종 config/webpack.config.server.js (external부분 책 오타)
const paths = require('./paths');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const nodeExternals = require('webpack-node-externals');
const webpack = require('webpack');
const getClientEnvironment = require('./env');
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
module.exports = {
mode: 'production',
entry: paths.ssrIndexJs,
target: 'node',
output: {
path: paths.ssrBuild,
filename: 'server.js',
chunkFilename: 'js/[name].chunk.js',
publicPath: paths.publicUrlOrPath,
},
module: {
rules: [
{
oneOf: [
// 자바스크립트를 위한 처리
// 기존 webpack.config.js 를 참고하여 작성
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve(
'babel-preset-react-app/webpack-overrides'
),
presets: [
[
require.resolve('babel-preset-react-app'),
{
runtime: 'automatic',
},
],
],
plugins: [
[
require.resolve('babel-plugin-named-asset-import'),
{
loaderMap: {
svg: {
ReactComponent:
'@svgr/webpack?-svgo,+titleProp,+ref![path]',
},
},
},
],
],
cacheDirectory: true,
cacheCompression: false,
compact: false,
},
},
// CSS 를 위한 처리
{
test: cssRegex,
exclude: cssModuleRegex,
// exportOnlyLocals: true 옵션을 설정해야 실제 css 파일을 생성하지 않습니다.
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
exportOnlyLocals: true,
},
},
},
// CSS Module 을 위한 처리
{
test: cssModuleRegex,
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
exportOnlyLocals: true,
getLocalIdent: getCSSModuleLocalIdent,
},
},
},
// Sass 를 위한 처리
{
test: sassRegex,
exclude: sassModuleRegex,
use: [
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 3,
modules: {
exportOnlyLocals: true,
},
},
},
require.resolve('sass-loader'),
],
},
// Sass + CSS Module 을 위한 처리
{
test: sassRegex,
exclude: sassModuleRegex,
use: [
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 3,
modules: {
exportOnlyLocals: true,
getLocalIdent: getCSSModuleLocalIdent,
},
},
},
require.resolve('sass-loader'),
],
},
// url-loader 를 위한 설정
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve('url-loader'),
options: {
emitFile: false, // 파일을 따로 저장하지 않는 옵션
limit: 10000, // 원래는 9.76KB가 넘어가면 파일로 저장하는데
// emitFile 값이 false 일땐 경로만 준비하고 파일은 저장하지 않습니다.
name: 'static/media/[name].[hash:8].[ext]',
},
},
// 위에서 설정된 확장자를 제외한 파일들은
// file-loader 를 사용합니다.
{
loader: require.resolve('file-loader'),
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
emitFile: false, // 파일을 따로 저장하지 않는 옵션
name: 'static/media/[name].[hash:8].[ext]',
},
},
],
},
],
},
resolve: {
modules: ['node_modules'],
},
externals: [
nodeExternals({
allowlist: [/@babel/],
}),
],
plugins: [
new webpack.DefinePlugin(env.stringified), // 환경변수를 주입해줍니다.
],
};
빌드 스크립트 작성하기
scripts/build.server.js
process.env.BABEL_ENV = "production";
process.env.NODE_ENV = "production";
process.on("unhandledRejection", (err) => {
throw err;
});
require("../config/env");
const fs = require("fs-extra");
const webpack = require("webpack");
const config = require("../config/webpack.config.server");
const paths = require("../config/paths");
function build() {
console.log("Creating server build...");
fs.emptyDirSync(paths.ssrBuild);
let compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
console.log(err);
return;
}
console.log(stats.toString());
});
});
}
build();
node scripts/build.server.js
아니 Cannot find module '@babel/plugin-syntax-jsx' 이거 모야 삭제하고 다시 하니까 돼
rm -rf node_modules
yarn
node scripts/build.server.js
node dist/server.js
package.json
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js",
"start:server": "node dist/server.js",
"build:server": "node scripts/build.server.js"
},
yarn build:server
yarn start:server
서버 코드 작성하기
yarn add express
index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom";
import App from "./App";
import path from "path";
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
const serverRender = (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버 사이드 렌더링을 해 줍니다.
const context = {};
const jsx = (
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
);
const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
res.send(root); // 클라이언트에게 결과물을 응답합니다.
};
const server = express.static(path.resolve("./build"), {
index: false, // "/" 경로에서 index.html을 보여 주지 않도록 설정
});
app.use(server); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
console.log("Running on http://localhost:5000");
});
그 다음 js와 css 파일을 불러오도록 html에 코드를 삽입해주어야 합니다. 불러와야 하는 파일 이름은 매번 빌드할 때마다 바뀌기 때문에 빌드하고 나서 만들어지는 asset-manifest.json 파일을 참고하여 불러오도록 작성합니다.
yarn build. => build/asset-manifest.json
index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom";
import App from "./App";
import path from "path";
import fs from "fs";
// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);
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, tags) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
<link href="${manifest.files["main.css"]}" rel="stylesheet" />
</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>
</body>
</html>
`;
}
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
const serverRender = (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버 사이드 렌더링을 해 줍니다.
const context = {};
const jsx = (
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
);
const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
res.send(createPage(root)); // 클라이언트에게 결과물을 응답합니다.
};
const server = express.static(path.resolve("./build"), {
index: false, // "/" 경로에서 index.html을 보여 주지 않도록 설정
});
app.use(server); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
console.log("Running on http://localhost:5000");
});
Network을 눌러서 서버 사이드 렌더링이 잘되었는지 검증해 보세요. 여기서 링크를 눌러 이동할 때는 클라이언트 렌더링이 되어야 합니다. 즉, 다른 링크를 클릭하여 다른 페이지로 이동할 때 네트워크 요청이 추가로 발생하지 않아야 합니다. 서버 사이드 렌더링을 구현하면 이렇게 첫 번째 렌더링은 서버를 통해 하지만, 그 이후에는 브라우저에서 처리합니다.
데이터 로딩
데이터 로딩을 한다는 것은 API 요청을 의미합니다.
redux-think 코드 준비하기
yarn add redux react-redux redux-thunk axios
PreloadContext 만들기
렌더링하기 전에 API를 요청한 뒤 스토어에 데이터를 담아야 하는데요. 서버 환경에서 이러한 작업을 하려면 constructor 메서드를 사용하거나 render 함수 자체에서 처리해야 합니다. 그리고 요청이 끝날 때까지 대기했다가 다시 렌더링해 주어야 하죠. 이 작업을 PreloadContext로 처리하겠습니다.
lib/PreloadContext.js
import { createContext, useContext } from "react";
// 클라이언트 환경: null
// 서버 환경: { done: false, promise: []}
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;
};
PreloadContext는 서버 사이드 렌더링을 하는 과정에서 처리해야 할 작업들을 실행하고, 만약 기다려야 하는 프로미스가 있다면 프로미스를 수집합니다. 모든 프로미스를 수집한 뒤, 수집된 프로미스들이 끝날 때까지 기다렸다가 그다음에 다시 렌더링하면 데이터가 채워진 상태로 컴포넌트들이 나타나게 됩니다.
Preloader 컴포넌트는 resolve라는 함수를 props로 받아 오며, 컴포넌트가 렌더링될 때 서버 환경에서만 resolve 함수를 호출해 준다.
containers/UsersContainer.js
import React, { useEffect } from "react";
import { connect } from "react-redux";
import Users from "../components/Users";
import { Preloader } from "../lib/PreloadContext";
import { getUsers } from "../modules/users";
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);
서버에서 리덕스 설정 및 PreloadContext 사용하기
index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
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 rootReducer from "./modules";
import PreloadContext from "./lib/PreloadContext";
// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);
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, tags) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
<link href="${manifest.files["main.css"]}" rel="stylesheet" />
</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>
</body>
</html>
`;
}
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
const serverRender = (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버 사이드 렌더링을 해 줍니다.
const context = {};
const store = createStore(rootReducer, applyMiddleware(thunk));
const preloadContext = {
done: false,
promises: [],
};
const jsx = (
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
);
ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup으로 한번 렌더링합니다.
try{
await Promise.all(preloadContext.promises); // 모든 프로미스를 기다립니다.
}catch(e){
return res.status(500);
}
preloadContext.done=true;
const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
res.send(createPage(root)); // 클라이언트에게 결과물을 응답합니다.
};
const server = express.static(path.resolve("./build"), {
index: false, // "/" 경로에서 index.html을 보여 주지 않도록 설정
});
app.use(server); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
console.log("Running on http://localhost:5000");
});
여기서 주의할 점은 서버가 실행될 때 스토어를 한 번만 만드는 것이 아니라, 요청이 들어올 때마다 새로운 스토어를 만든다는 것입니다.
첫 번째 렌더링을 할 때는 renderToString 대신 renderStaticMarkup이라는 함수를 사용했습니다. 이 함수는 리액트를 사용하여 정적인 페이지를 만들 때 사용합니다. 이 함수로 만든 리액트 렌더링 결과물은 클라이언트 쪽에서 HTML DOM 인터랙션을 지원하기 힘듭니다.
지금 단계에서 renderToString 대신 renderToStaticMarkup 함수를 사용한 이유는 그저 Preloader로 넣어 주었던 함수를 호출하기 위해서입니다. 또 이 함수의 처리 속도가 renderToString보다 좀 더 빠르기 때문입니다.
스크립트로 스토어 초기 상태 주입하기
지금까지 작성한 코드는 API를 통해 받아 온 데이터를 렌더링하지만, 렌더링하는 과정에서 만들어진 스토어의 상태를 브라우저에서 재사용하지 못하는 상황입니다. 서버에서 만들어 준 상태를 브라우저에서 재사용하려면, 현재 스토어 상태를 문자열로 변환한 뒤 스크립트로 주입해 주어야 합니다.
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
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 rootReducer from "./modules";
import PreloadContext from "./lib/PreloadContext";
// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);
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, stateScript) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
<link href="${manifest.files["main.css"]}" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${root}
</div>
${stateScript}
<script src="${manifest.files["runtime-main.js"]}"></script>
${chunks}
<script src="${manifest.files["main.js"]}"></script>
</body>
</html>
`;
}
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
const serverRender = async (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버 사이드 렌더링을 해 줍니다.
const context = {};
const store = createStore(rootReducer, applyMiddleware(thunk));
const preloadContext = {
done: false,
promises: [],
};
const jsx = (
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
);
ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup으로 한번 렌더링합니다.
try {
await Promise.all(preloadContext.promises); // 모든 프로미스를 기다립니다.
} catch (e) {
return res.status(500);
}
preloadContext.done = true;
const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
// JSON을 문자열로 변환하고 악성 스크립트가 실행되는 것을 방지하기 위해 <를 치환 처리
// https://redux.js.org/recipes/server-rendering#security-considerations
const stateString = JSON.stringify(store.getState()).replace(/</g, "\\u003c");
const stateScript = `<script>__PRELOADED_STATE__=${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입합니다.
res.send(createPage(root, stateScript)); // 클라이언트에게 결과물을 응답합니다.
};
const server = express.static(path.resolve("./build"), {
index: false, // "/" 경로에서 index.html을 보여 주지 않도록 설정
});
app.use(server); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
console.log("Running on http://localhost:5000");
});
yarn build
yarn build:server
yarn start:server
결과물 확인 local5000/users에서
redux-saga 코드 준비하기
yarn add redux-saga
user.js 추가
import axios from "axios";
import { call, put, takeEvery } from "redux-saga/effects";
const GET_USERS_PENDING = "users/GET=USERS=PENDING";
const GET_USERS_SUCCESS = "users/GET=USERS=SUCCESS";
const GET_USERS_FAILURE = "users/GET=USERS=FAILURE";
const GET_USER = "users/GET_USER";
const GET_USER_SUCCESS = "users/GET_USER_SUCCESS";
const GET_USER_FAILURE = "users/GET_USER_FAILURE";
const getUsersPending = () => ({ type: GET_USERS_PENDING });
const getUsersSuccess = (payload) => ({ type: GET_USERS_SUCCESS, payload });
const getUsersFailure = (payload) => ({
type: GET_USERS_FAILURE,
error: true,
payload,
});
export const getUser = (id) => ({ type: GET_USER, payload: id });
const getUserSuccess = (data) => ({ type: GET_USER_SUCCESS, payload: data });
const getUserFailure = (error) => ({
type: GET_USER_FAILURE,
payload: error,
error: true,
});
export const getUsers = () => async (dispatch) => {
try {
dispatch(getUsersPending());
const response = await axios.get(
"https://jsonplaceholder.typicode.com/users"
);
dispatch(getUsersSuccess(response));
} catch (e) {
dispatch(getUsersFailure(e));
throw e;
}
};
const getUserById = (id) =>
axios.get(`https://jsonplaceholder.typicode.com/users/${id}`);
function* getUserSaga(action) {
try {
const response = yield call(getUserById, action.payload);
yield put(getUserSuccess(response.data));
} catch (e) {
yield put(getUserFailure(e));
}
}
export function* usersSaga() {
yield takeEvery(GET_USER, getUserSaga);
}
const initialState = {
users: null,
user: null,
loading: {
users: false,
user: false,
},
error: {
users: null,
user: null,
},
};
function users(state = initialState, action) {
switch (action.type) {
case GET_USERS_PENDING:
return { ...state, loading: { ...state.loading, users: true } };
case GET_USERS_SUCCESS:
return {
...state,
loading: { ...state.loading, users: false },
users: action.payload.data,
};
case GET_USERS_FAILURE:
return {
...state,
loading: { ...state.loading, users: false },
error: { ...state.error, users: action.payload },
};
case GET_USER:
return {
...state,
loading: { ...state.loading, user: true },
error: { ...state.error, user: null },
};
case GET_USER_SUCCESS:
return {
...state,
loading: { ...state.loading, user: false },
user: action.payload,
};
case GET_USER_FAILURE:
return {
...state,
loading: { ...state.loading, user: false },
error: { ...state.error, user: action.payload },
};
default:
return state;
}
}
export default users;
modules/index.js, src/index.js에 사가 추가
index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
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 rootReducer, { rootSaga } from "./modules";
import PreloadContext from "./lib/PreloadContext";
import createSagaMiddleware, { END } from "redux-saga";
// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);
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, stateScript) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
<link href="${manifest.files["main.css"]}" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${root}
</div>
${stateScript}
<script src="${manifest.files["runtime-main.js"]}"></script>
${chunks}
<script src="${manifest.files["main.js"]}"></script>
</body>
</html>
`;
}
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
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: [],
};
const jsx = (
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
);
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); // 렌더링을 하고
// JSON을 문자열로 변환하고 악성 스크립트가 실행되는 것을 방지하기 위해 <를 치환 처리
// https://redux.js.org/recipes/server-rendering#security-considerations
const stateString = JSON.stringify(store.getState()).replace(/</g, "\\u003c");
const stateScript = `<script>__PRELOADED_STATE__=${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입합니다.
res.send(createPage(root, stateScript)); // 클라이언트에게 결과물을 응답합니다.
};
const server = express.static(path.resolve("./build"), {
index: false, // "/" 경로에서 index.html을 보여 주지 않도록 설정
});
app.use(server); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
console.log("Running on http://localhost:5000");
});
usePreloader Hook 만들어서 사용하기
import { createContext, useContext } from "react";
// 클라이언트 환경: null
// 서버 환경: { done: false, promise: []}
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(Preloader);
if (!preloadContext) return null;
if (preloadContext.done) return null;
preloadContext.promises.push(Promise.resolve(resolve()));
};
서버 사이드 렌더링과 코드 스플리팅
yarn add @loadable/component @loadable/server @loadable/webpack-plugin @loadable/babel-plugin
라우트 컴포넌트 스플리팅하기
현재 프로젝트에서 라우트를 위해 사용하고 있는 BluePage, RedPage, UserPage를 스플리팅
웹팩과 babel 플러그인 적용
package.json
"babel": {
"presets": [
"react-app"
],
//요기서부터 추가
"plugins":[
"@loadable/babel-plugin"
]
}
webpack.config.js
const LoadablePlugin = require('@loadable/webpack-plugin');
plugins: [
new LoadablePlugin(), //여기추가
///////////////////////////////////////////////////////////////////////////////////
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
그 다음 yarn build 실행하면 build 디렉터리에 lodable-stats.json 파일 만들어짐
이 파일은 각 컴포넌트의 코드가 어떤 청크(chunk) 파일에 들어가 있는지에 대한 정보를 가지고 있습니다. 서버 사이드 렌더링을 할 때 이 파일을 참고하여 어떤 컴포넌트가 렌더링되었는지에 따라 어떤 파일들을 사전에 불러와야 할지 설정할 수 있습니다.
필요한 청크 파일 경로 추출하기
서버 사이드 렌더링 후 브라우저에서 어떤 파일을 사전에 불러와야 할지 알아내고 해당 파일들의 경로를 추출하기 위해 Loadable Components에서 제공하는 ChunkExtractor와 ChunkExtractorManager를 사용합니다.
이제 Loadable Components를 통해 파일 경로를 조회하므로 기존에 asset-manifest.json을 확인하던 코드는 지워줍니다.
index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
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 rootReducer, { rootSaga } from "./modules";
import PreloadContext from "./lib/PreloadContext";
import createSagaMiddleware, { END } from "redux-saga";
import { ChunkExtractor, ChunkExtractorManager } from "@loadable/server";
const statsFile = path.resolve("./build/lodable-stats.json");
function createPage(root, tags) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
${tags.styles}
${tags.links}
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${root}
</div>
${tags.scripts}
</body>
</html>
`;
}
const app = express();
// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
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 = (
<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); // 렌더링을 하고
// JSON을 문자열로 변환하고 악성 스크립트가 실행되는 것을 방지하기 위해 <를 치환 처리
// https://redux.js.org/recipes/server-rendering#security-considerations
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 server = express.static(path.resolve("./build"), {
index: false, // "/" 경로에서 index.html을 보여 주지 않도록 설정
});
app.use(server); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
console.log("Running on http://localhost:5000");
});
loadableReady와 hydrate
Loadable Components를 사용하면 성능을 최적화하기 위해 모든 자바스크립트 파일을 동시에 받아 옵니다. 모든 스크립트가 로딩되고 나서 렌더링하도록 처리하기 위해서는 loadableReady라는 함수를 사용해 주어야 합니다. 추가로 리액트에서는 render 함수 대신에 사용할 수 있는 hydrate라는 함수가 있습니다. 이 함수는 기존에 서버 사이드 렌더링된 결과물이 이미 있을 경우 새로 렌더링하지 않고 기존에 존재하는 UI에 이벤트만 연동하여 애플리케이션을 초기 구동할 때 필요한 리소스를 최소화함으로써 성능을 최적화해 줍니다. 이를 적용하려면 index.js를 다음과 같이 수정해 주세요.
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { BrowserRouter } from "react-router-dom";
import { applyMiddleware, createStore } from "redux";
import rootReducer, { rootSaga } from "./modules";
import thunk from "redux-thunk";
import { Provider } from "react-redux";
import { composeWithDevTools } from "redux-devtools-extension";
import createSagaMiddleWare from "redux-saga";
import { loadableReady } from "@loadable/component";
const sagaMiddleware = createSagaMiddleWare();
const store = createStore(
rootReducer,
window.__PRELOADED_STATE__, // 이 값을 초기상태로 사용함
composeWithDevTools(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);
}
// 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();
개어려워.............. 복습 계속해야돼
'책 > 리다기' 카테고리의 다른 글
리다기 정리13(몽고디비) (0) | 2021.02.04 |
---|---|
리다기 정리 12(백엔드 Koa) (0) | 2021.02.04 |
리다기 정리10(코드 스플리팅) (0) | 2021.01.28 |
리다기 정리9(리덕스 미들웨어) (0) | 2021.01.27 |
리다기 정리8(리덕스) (0) | 2021.01.25 |