유저가 요청 할 때마다 HTML 을 새로 불러오고,
그때 그때 페이지가 새로고침된다
처음에 한번 불러오고
그 다음부터는 필요한 데이터만 불러와서
자바스크립트를 통하여 이를 화면에 반영한다
싱글페이지앱 이라고 해서,
한 종류의 화면만 있는건 아니다
페이스북에서 만든 공식 라우터도
딱히 없음
직접 만들어서 쓸 수도 있음
써드파티 라이브러리지만,
거의 공식 라우터나 마찬가지인 리액트 라우터
주소를 읽고, 필요한 정보들을 props 로 전달해주는 역할
앱의 규모가 커지면, 자바스크립트 파일이 커짐
예) 블로그 포스트만 보고싶을 뿐인데,
블로그 홈, 글쓰기, 관리자 페이지 등에서 필요한 컴포넌트도 불러오게됨..
걱정마세요!
코드 스플리팅
create-react-app 으로 프로젝트 생성
$ create-react-app react-router-tutorial
리액트 라우터 설치
$ yarn add react-router-dom
$ yarn add cross-env --dev
react-router-dom 브라우저에서 사용되는 리액트 라우터
cross-env 환경변수 NODE_PATH 를 설정하기 위한 라이브러리
파일 제거
디렉토리 생성
NODE_ENV 설정 (package.json)
"scripts": {
"start": "cross-env NODE_PATH=src react-scripts start",
"build": "cross-env NODE_PATH=src react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
'../components/Something'
'components/Something'
src/shared/App.js
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<div>
Hello React-Router
</div>
);
}
}
export default App;
src/client/Root.js
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import App from 'shared/App';
const Root = () => (
<BrowserRouter>
<App/>
</BrowserRouter>
);
export default Root;
src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Root from './client/Root';
import registerServiceWorker from './registerServiceWorker';
import './index.css';
ReactDOM.render(<Root />, document.getElementById('root'));
registerServiceWorker();
서버 시작
$ yarn start
src/pages/Home.js
import React from 'react';
const Home = () => {
return (
<div>
<h2>
홈
</h2>
</div>
);
};
export default Home;
src/pages/About.js
import React from 'react';
const About = () => {
return (
<div>
<h2>About</h2>
</div>
);
};
export default About;
페이지 인덱스 생성
src/pages/index.js
export { default as Home } from './Home';
export { default as About } from './About';
라우트를 설정 할 땐 Route 컴포넌트 사용
경로는 path 값으로 설정
src/shared/App.js
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { Home, About } from 'pages';
class App extends Component {
render() {
return (
<div>
<Route exact path="/" component={Home}/>
<Route path="/about" component={About}/>
</div>
);
}
}
export default App;
잘 보여지는지 확인
지금은 개발서버에서
historyApiFallback 이 활성화 되어있다.
* 어떤페이지던, 리액트 앱(index.html)으로 떨굼
실제 서버에서는, 각 라우트마다 index.html 로 연결
혹은,
404 페이지를 index.html 으로
exact 를 지워볼까?
라우트의 경로에 값을 넣어줄때는:
- params
- query
라우트로 설정한 컴포넌트는,
다음 3가지의 props 를 전달받는다:
- history push, replace 등을 통해 다른경로로 이동
- location 현재 경로에 대한 정보를 가지고 있다 (query)
- match 어떤 라우트에 매칭이 되어있는지에 대한 정보를 가지고있다 (params)
/about/:name
src/shared/App.js
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { Home, About } from 'pages';
class App extends Component {
render() {
return (
<div>
<Route exact path="/" component={Home}/>
<Route path="/about" component={About}/>
<Route path="/about/:name" component={About}/>
</div>
);
}
}
export default App;
src/pages/About.js
import React from 'react';
const About = ({match}) => {
return (
<div>
<h2>About {match.params.name}</h2>
</div>
);
};
export default About;
About 이 중복됐군!
exact 를 사용하자
Switch 컴포넌트로 Route 들을 감싸면,
그 중에서 가장 처음 일치하는 라우트만 보여주고
그 다음은 무시
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About } from 'pages';
class App extends Component {
render() {
return (
<div>
<Route exact path="/" component={Home}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
</div>
);
}
}
export default App;
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About } from 'pages';
class App extends Component {
render() {
return (
<div>
<Route exact path="/" component={Home}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
</div>
);
}
}
export default App;
리액트 라우터 v3 까지는, URL 쿼리를 자체적으로 파싱해주었지만,
더 이상은 naver
URL 쿼리 파싱 방식이 여러종류로 나뉘어져있어서
그 중 개발자들이 알아서 선택해서 사용
$ yarn add query-string
src/pages/About.js
import React from 'react';
import queryString from 'query-string';
const About = ({location, match}) => {
const query = queryString.parse(location.search);
console.log(query);
return (
<div>
<h2>About {match.params.name}</h2>
</div>
);
};
export default About;
/about/foo?detail=true
들어가서, 개발자콘솔 확인
이 값에 따라서 조건부 렌더링을 해보자
src/pages/About.js
import React from 'react';
import queryString from 'query-string';
const About = ({location, match}) => {
const query = queryString.parse(location.search);
const detail = query.detail === 'true';
return (
<div>
<h2>About {match.params.name}</h2>
{detail && 'detail: blahblah'}
</div>
);
};
export default About;
주의 사항:
params 나 url query 는 문자열이다.
비교를 할 때에는, 타입을 동일하게 설정 후 비교!
앱 내에서 다른 라우트로 이동 할 때
<a href="...">이거 쓰면 안됨</a>
(새로고침을 해버린다)
Menu 컴포넌트 만들기
src/components/Menu.js
import React from 'react';
import { Link } from 'react-router-dom';
const Menu = () => {
return (
<div>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/about/foo">About Foo</Link></li>
</ul>
<hr/>
</div>
);
};
export default Menu;
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About } from 'pages';
import Menu from 'components/Menu';
class App extends Component {
render() {
return (
<div>
<Menu/>
<Route exact path="/" component={Home}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
</div>
);
}
}
export default App;
설정한 URL 이 활성화되면,
특정 스타일 / 클래스 설정
src/components/Menu.js
import React from 'react';
import { NavLink } from 'react-router-dom';
const Menu = () => {
const activeStyle = {
color: 'green',
fontSize: '2rem'
};
return (
<div>
<ul>
<li><NavLink exact to="/" activeStyle={activeStyle}>Home</NavLink></li>
<li><NavLink exact to="/about" activeStyle={activeStyle}>About</NavLink></li>
<li><NavLink to="/about/foo" activeStyle={activeStyle}>About Foo</NavLink></li>
</ul>
<hr/>
</div>
);
};
export default Menu;
리액트 라우터 v3 에서 v4 로 업데이트되면서
크게 바뀐 점
// RR v3
<Route path="foo" component={Foo}>
<Route path=":id" component={Bar}/>
</Route>
// 모든 Route 를 최상위에서 한꺼번에 지정해야됐었음
Post 페이지 컴포넌트 만들기
src/pages/Post.js
import React from 'react';
const Post = ({match}) => {
return (
<div>
포스트 {match.params.id}
</div>
);
};
export default Post;
Posts 페이지 컴포넌트 만들기
src/pages/Posts.js
import React from 'react';
import { Link, Route } from 'react-router-dom';
import { Post } from 'pages';
const Posts = ({match}) => {
return (
<div>
<h2>Post List</h2>
<ul>
<li><Link to={`${match.url}/1`}>Post #1</Link></li>
<li><Link to={`${match.url}/2`}>Post #2</Link></li>
<li><Link to={`${match.url}/3`}>Post #3</Link></li>
<li><Link to={`${match.url}/4`}>Post #4</Link></li>
</ul>
<Route exact path={match.url} render={()=>(<h3>Please select any post</h3>)}/>
<Route path={`${match.url}/:id`} component={Post}/>
</div>
);
};
export default Posts;
페이지 인덱스 수정
src/pages/index.js
export { default as Home } from './Home';
export { default as About } from './About';
export { default as Posts } from './Posts';
export { default as Post } from './Post';
/posts 경로를 위한 라우트 설정
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About, Posts } from 'pages';
import Menu from 'components/Menu';
class App extends Component {
render() {
return (
<div>
<Menu/>
<Route exact path="/" component={Home}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
<Route path="/posts" component={Posts}/>
</div>
);
}
}
export default App;
Menu 에 /posts 넣기
src/components/Menu.js
import React from 'react';
import { NavLink } from 'react-router-dom';
const Menu = () => {
const activeStyle = {
color: 'green',
fontSize: '2rem'
};
return (
<div>
<ul>
<li><NavLink exact to="/" activeStyle={activeStyle}>Home</NavLink></li>
<li><NavLink exact to="/about" activeStyle={activeStyle}>About</NavLink></li>
<li><NavLink to="/about/foo" activeStyle={activeStyle}>About Foo</NavLink></li>
<li><NavLink to="/posts" activeStyle={activeStyle}>Posts</NavLink></li>
</ul>
<hr/>
</div>
);
};
export default Menu;
헷갈리는 값들
location.pathname 현재 브라우저상의 위치
match Route 와 직접적으로 관계된 값
자바스크립트 번들파일에 어플리케이션에 필요한 모든 로직을 불러온다.
규모가 커지면, 용량이커지고... 초기로딩속도 또한 지연됨.
파일을 여러개로 나뉘어서 이 문제점을 해결하자!
코드 스플리팅
한개의 파일에서 모두 불러오는것이 아닌,
라이브러리나 컴포넌트가 실제로 필요 해질 때 불러온다.
웹팩 / 바벨 환경설정 밖으로 빼내기
$ yarn eject
프로젝트에서 전역적(globally)으로 사용되는
파일들을 분리시키는것
예) react, react-dom, redux, react-redux, react-router-dom,
styled-components ....
config/webpack.config.dev.js 를 확인해보자
config/webpack.config.dev.js
entry: [
require.resolve('react-dev-utils/webpackHotDevClient'),
require.resolve('./polyfills'),
require.resolve('react-error-overlay'),
paths.appIndexJs,
]
entry 에 있는 파일들을 불러와서,
거기서부터 시작해서 import 한것들에 따라서
번들링을 하는 것.
entry 가 배열형태로 되어있으면,
vendor 설정을 하지 못한다!
객체 형태로 변환해보자
config/webpack.config.dev.js
entry: {
dev: 'react-error-overlay',
vendor: [
require.resolve('./polyfills'),
'react',
'react-dom',
'react-router-dom'
],
app: ['react-dev-utils/webpackHotDevClient', paths.appIndexJs]
}
이런식으로, 각 모듈들을 여러 파일로 분리 할 수 있음
webpackHotDevClient 와 app 은 함께 있어야함
사실, 코드 스플리팅은 개발모드에선 필요없다!
애초에 로컬에서 작업하는건데 ...
서버사이드 렌더링 할때도 필요없음
프로덕션에서만 코드스플리팅을 적용 할 것!
output 의 filename 과 chunkFilename 수정
config/webpack.config.dev.js - output
filename: 'static/js/[name].[hash].js',
chunkFilename: 'static/js/[name].[chunkhash].chunk.js',
[hash]: 앱이 빌드 될 때 마다 새로운 값이 생성됨
[chunkhash]: 웹팩설정에서 분리시킨 코드 말고,
프로젝트 코드상에서 직접 분리시킬 때 사용됨
파일은 여러개인데... app 파일이 왜 vendor 보다 클까?
지금은, entry 가 여러개일뿐,
저장하는 파일이 늘어났을뿐 중복되는 코드가 생략되진 않음.
vendor 코드가 app 안에 그대로 남아있음..
CommonsChunkPlugin!
config/webpack.config.dev.js - plugins
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<div>
Counter
</div>
);
}
}
export default App;
지금은, 준비작업만 했을 뿐..
SplitMe 라는 컴포넌트를 만들어보자
src/components/SplitMe.js
import React from 'react';
const SplitMe = () => {
return (
<h3>
SplitMe
</h3>
);
};
export default SplitMe;
평소랑 똑같음.. 근데, 불러올 때 다름!
import 를 최상단에서 하는게 아니라,
특정 함수에서 불러오도록 작성.
import from 이 아닌, import('...'),
반환값은 Promise
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About, Posts } from 'pages';
import Menu from 'components/Menu';
class App extends Component {
state = {
SplitMe: null
}
showSplitMe = () => {
// 비동기적으로 코드를 불러옵니다. 함수의 결과는 Promise 를 반환합니다.
// import() 는 모듈의 전체 네임스페이스를 불러오므로, default 를 직접 지정해주어야합니다.
import('components/SplitMe').then(({default: Component}) => {
// 불러오고 난 다음엔 컴포넌트를 state 에 집어넣습니다.
this.setState({
SplitMe: Component
});
});
}
render() {
const { SplitMe } = this.state; // state 에 담겨있는 SplitMe 에 대한 레퍼런스를 만들고
return (
<div>
<Menu/>
{ SplitMe && <SplitMe/> /* SplitMe 가 유효하면 렌더링을 해줍니다 */}
<button onClick={this.showSplitMe}>ClickMe</button>
<Route exact path="/" component={Home}/>
<Route path="/posts" component={Posts}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
</div>
);
}
}
export default App;
브라우저에서 확인해보자
코드 스플리팅은 결국,
처음부터 불러오지 않고,
추 후 파일이 필요해 질 때, 비동기적으로 불러온다음에
사용하는 것.
코드 스플리팅 할 때 마다
state 관리하기 귀찮은데...
페이스북 개발팀원 Andrew Clark (acdlite) 의 코드
// https://gist.github.com/acdlite/a68433004f9d6b4cbc83b5cc3990c194
function asyncComponent(getComponent) {
return class AsyncComponent extends React.Component {
static Component = null;
state = { Component: AsyncComponent.Component };
componentWillMount() {
if (!this.state.Component) {
getComponent().then(({default: Component}) => {
AsyncComponent.Component = Component
this.setState({ Component })
})
}
}
render() {
const { Component } = this.state
if (Component) {
return <Component {...this.props} />
}
return null
}
}
}
const Foo = asyncComponent(() => import('./Foo'))
const Bar = asyncComponent(() => import('./Bar'))
라우트를 기준으로 코드스플리팅을 해보자!
아까 봤던 코드를 lib 디렉토리에 저장
src/lib/asyncRoute.js
import React from 'react';
export default function asyncComponent(getComponent) {
return class AsyncComponent extends React.Component {
static Component = null;
state = { Component: AsyncComponent.Component };
componentWillMount() {
if (!this.state.Component) {
getComponent().then(({default: Component}) => {
AsyncComponent.Component = Component
this.setState({ Component })
})
}
}
render() {
const { Component } = this.state
if (Component) {
return <Component {...this.props} />
}
return null
}
}
}
코드 스플리팅을 위한 라우트 인덱스 만들기
src/pages/index.async.js
import asyncRoute from 'lib/asyncRoute';
export const Home = asyncRoute(() => import('./Home'));
export const About = asyncRoute(() => import('./About'));
export const Post = asyncRoute(() => import('./Post'));
export const Posts = asyncRoute(() => import('./Posts'));
App 컴포넌트에서 pages/index.async.js 임시 치환
import { Home, About, Posts } from 'pages/index.async.js';
잘 되는걸 확인했다면 다시 'pages' 로
import { Home, About, Posts } from 'pages';
index.async.js 를 불러와서 사용하는건
production 에서만!
NormalModuleReplacementPlugin
웹팩에서 파일을 불러올때 다른 이름을 가진 파일을 불러올수있게함
config/webpack.config.prod.js - entry
entry: {
vendor: [
require.resolve('./polyfills'),
'react',
'react-dom',
'react-router-dom'
],
app: paths.appIndexJs
},
config/webpack.config.prod.js - plugins
plugins: [
new webpack.NormalModuleReplacementPlugin(
/^pages$/,
'pages/index.async.js'
),
// CommonsChunkPlugin 도 적용하세요.
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
}),
$ yarn build
코드 스플리팅은 성공!
기본적으로는 async.js 파일을 불러오도록 하고,
개발서버 환경설정에서 다음 코드를 적용함
new webpack.NormalModuleReplacementPlugin(
/.*.async.js$/,
function(resource) {
resource.request = resource.request.replace('.async', '');
}
),
뒤에 .async 가 붙어있으면 이 부분을 지우기
코드가 변경되었을 때,
새로고침 하지 않고 바뀐 부분만 빠르게 교체해주는 도구
필수는 아니지만, 앱의 규모가 커지면 코드가 수정될때마다
딜레이가 발생....
개발자 경험을 향상하자!
$ yarn add react-hot-loader@next
config/webpack.config.dev.js - entry
entry: [
'react-hot-loader/patch',
'react-dev-utils/webpackHotDevClient',
'react-error-overlay',
require.resolve('./polyfills'),
paths.appIndexJs
],
config/webpack.config.dev.js - babel-loader
{
test: /\.(js|jsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
cacheDirectory: true,
plugins: [
'react-hot-loader/babel'
]
},
}
서버 재시작
src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Root from './client/Root';
import registerServiceWorker from './registerServiceWorker';
import { AppContainer } from 'react-hot-loader';
import './index.css';
const render = Component => {
ReactDOM.render(
<AppContainer>
<Component />
</AppContainer>,
document.getElementById('root')
)
}
render(Root)
if (module.hot) {
module.hot.accept('./client/Root', () => { render(Root) })
}
registerServiceWorker();
주의: Code Splitting 하면 redux-hot-loader 가 제대로 작동하지 않기 때문에, 개발환경에서는 코드스플리팅을 안하는것이 좋음
앞으로 개발하면서, 서버사이드렌더링을
하지 않을 확률이 높음
필수작업은 아니기때문..
하지만, 필요해질수도 있는데..
서버사이드 렌더링을 처음 하게 될 땐,
엄청난 삽질이 예약되어있다.
나중에 그 삽질을 덜기 위해서,
오늘 서버사이드 렌더링의 흐름을 알아보자
검색엔진 최적화
자바스크립트 위주의 프로젝트는,
자바스크립트 엔진이 돌아가지 않으면
원하는 정보를 표시해주지 않음
내용이 비어있다!
보통 검색엔진 크롤러에는 자바스크립트엔진이 없음..
Facebook / Kakao 크롤러도 마찬가지
SNS 공유를 위한거면, meta 태그만으로도 충분 함
(meta 태그는 주소에 따라 서버에서 넣어주어야함)
자바스크립트가 로드 되기도 전에, 첫 렌더링 된 html 을 클라이언트에게
전달하기 때문에 초기 로딩속도를 많이 줄여줌
자바스크립트가 로딩되기전에도 유저가 컨텐츠 이용 가능!
서버 사양이 좋지 않다면, SSR은 부적합
훨씬 가벼움
Express 도 가볍긴하지만,
더 가벼워질수있음.
하지만, 그 작업을 Express v5 에 반영하면
기존에 v3 → v4 업데이트때 구조가 너무 많이바뀌어
사람들이 혼란스럽기때문에, 이름을 바꿔서 만든것이 Koa
Express 는 옛날의 Express 가 아니고..
어쩌면.. 죽어가고 있는건지도..
유지보수는 되고 있지만,
확실한건 인지도를 잃어가고있다.
"magic" 이 없음
(편의를 위해 추상화가 너무 많이 되어있어서, 어떻게 작동하는건진 모르는데 작동하는 것, magic.)
최신 Node.js 에 맞춰서 설계되어
async/await 문법을 사용 할 수 있음
(권장되는 Node 버전: 7.x)
이하버전 사용 시 babel 사용 필요
안정적임,
코드가 자주 바뀌지 않음
서버사이드 렌더링을 위하여
Koa 에만 있는 기능을 사용하는것은 아니기 때문에
다른 웹 프레임워크도 사용 가능함
깔끔하고 명료하고,
요즘 Node.js 에서 자주 사용되기 때문에, Koa 를 선택!
설치하기
$ yarn add koa
서버 작성하기
server/index.js
const Koa = require('koa');
const app = new Koa();
app.use(ctx => {
ctx.body = 'Hello World';
});
app.listen(3001);
서버 실행하기
$ node server
(CSS, JS, HTML 파일들)
koa-static 미들웨어 설치
$ yarn add koa-static
server/index.js
const Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const app = new Koa();
app.use(serve(path.resolve(__dirname, '../build/')));
app.use(ctx => {
ctx.body = 'Hello World';
});
app.listen(3001);
리액트앱 빌드 & 서버 재시작
$ yarn build
$ node server
/about 페이지에도 들어가보자
/ 경로로 들어오면, build 디렉토리에서 index.html
그 외에는, 준비된게 없기 때문에 Hello World 가 뜸
클라이언트 라우팅이 제대로 작동하려면,
서버에서 준비되지 않은 라우트로 들어오면
일단 리액트앱을 띄워주어야함
(정규식 등으로 서버측에서 한번 검증 할 수도 있고요..)
hello world 하던 곳에서
index.html 불러와서 보여주자!
server/index.js
const Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa();
const indexHtml = fs.readFileSync(path.resolve(__dirname, '../build/index.html'), { encoding: 'utf8' });
app.use(serve(path.resolve(__dirname, '../build/')));
app.use(ctx => {
ctx.body = indexHtml;
});
app.listen(3001);
이제, 캐쉬를 비워도 /about 들어갔을 때
잘 작동함!
지금은, 단순히 리액트 어플리케이션을 제공만 해준 것이며
SSR 은 아직 구현하지 않았음
리액트를 렌더링 하려면,
서버측에서 리액트 컴포넌트를 불러와서 사용해야 함
Node.js 에선 JSX 못쓰는데..
how ??
여러가지 옵션이 있다
1. babel-node 을 통해 런타임에서 babel 사용하기
프로덕션에서 사용하기에 안좋음.
서버에서 코드를 변환하기위해 불필요한 자원이 사용됨
2. babel-register 를 불러와서 사용하기
이 또한 런타임에서 코드를 변환하여 사용하는 방식
1번과 같은 이유로 비추
3. babel 을 통하여 서버를 빌드한 다음에 사용하기
개발을 할 땐 1번 / 2번을 쓰고,
실제 프로덕션에선 babel 을 통해 아예 변환된 코드를 사용하면 괜찮긴 하지만..
서버를 수정 할 때마다 transpile 해야 되므로,
개발중에 코드 수정 할 때마다 딜레이 발생.. 불편함
4. webpack 을 통해 리액트 관련 코드만 빌드해서 사용
이 방법이 성능상, 개발 흐름상 가장 좋음.
babel 은 클라이언트쪽에서만 사용되는것이기 때문에,
리액트 관련 코드만 미리 webpack 으로 번들링하고,
이를 서버에서 require 해서 사용하는것.
서버 수정할때마다 새로 빌드 할 필요 없어서 좋음
src/server/render.js
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router';
import App from 'shared/App';
const render = (location) => ReactDOMServer.renderToString(
<StaticRouter location={location}>
<App/>
</StaticRouter>
);
export default render;
config/paths.js - module.exports
module.exports = {
/* 생략 */
serverRenderJs: resolveApp('src/server/render.js'), // 서버용 엔트리 경로
server: resolveApp('server/render') // 서버렌더링용 모듈 번들링 후 저장 경로
};
서버용 웹팩 설정은
webpack.config.server.js,
webpack.config.prod.js 를 기반으로 만든다
서버에선, js, jsx, json 확장자 외에는 로드 할 필요가 없으니,
무시해야 하는데, 이때 사용하는것은
ignore-loader
config/webpack.config.server.js
'use strict';
const path = require('path');
const webpack = require('webpack');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const getClientEnvironment = require('./env');
const paths = require('./paths');
const publicUrl = '';
const env = getClientEnvironment(publicUrl);
module.exports = {
entry: paths.serverRenderJs,
// Node.js 내장 모듈과 충돌이 일어나지 않으며 require 로 불러올 수 있는 형태로 번들링합니다
target: 'node',
output: {
// 정해준 서버 경로에 render.js 라는 파일명으로 저장합니다
path: paths.server,
filename: 'render.js',
libraryTarget: 'commonjs2' // node 에서 불러올 수 있도록, commonjs2 스타일로 번들링 합니다
},
resolve: {
modules: ['node_modules', paths.appNodeModules].concat(
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
extensions: ['.js', '.json', '.jsx'],
},
module: {
strictExportPresence: true,
rules: [
// 자바스크립트 이외의 파일들을 무시합니다.
{
exclude: [
/\.(js|jsx)$/,
/\.json$/
],
loader: 'ignore',
},
// 자바스크립트는 Babel 을 통하여 트랜스파일링합니다
{
test: /\.(js|jsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
cacheDirectory: true,
},
}
],
},
plugins: [
// 필수 플러그인만 넣어줍니다
new webpack.DefinePlugin(env.stringified),
new CaseSensitivePathsPlugin(),
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
]
};
기존 build.js 를 기반으로 수정됨
scripts/build.server.js
'use strict';
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
/* 나중에 클라이언트쪽 코드에서 process.env.APP_ENV 값을 통하여
서버일때만, 혹은 브라우저일때만 특정한 작업을 하도록 설정 할 수 있습니다. */
process.env.APP_ENV = 'server';
process.on('unhandledRejection', err => {
throw err;
});
require('../config/env');
const webpack = require('webpack');
const config = require('../config/webpack.config.server'); // 서버용 환경설정을 지정
const paths = require('../config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
if (!checkRequiredFiles([paths.serverRenderJs])) {
process.exit(1);
}
function build() {
console.log('Creating an server production build...');
let compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
return reject(err);
}
const messages = formatWebpackMessages(stats.toJson({}, true));
if (messages.errors.length) {
return reject(new Error(messages.errors.join('\n\n')));
}
return resolve({
stats,
warnings: messages.warnings,
});
});
});
}
build();
config/env.js - getClientEnvironment
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
NODE_ENV: process.env.NODE_ENV || 'development',
PUBLIC_URL: publicUrl,
// APP_ENV 추가
APP_ENV: process.env.APP_ENV || 'browser'
}
);
// (...)
package.json - scripts
"scripts": {
"start": "cross-env NODE_PATH=src node scripts/start.js",
"start:server": "node server",
"build": "cross-env NODE_PATH=src node scripts/build.js",
"build:server": "cross-env NODE_PATH=src node scripts/build.server.js",
"test": "node scripts/test.js --env=jsdom"
},
서버용 빌드 진행
$ yarn build:server
server/render 경로에 render.js 만들어졌는지 체크
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
const rendered = render(location);
// 해당 문자열을, 템플릿에 있는 '<div id="root"></div> 사이에 넣어줍니다.
const page = template.replace('<div id="root"></div>', `<div id="root">${rendered}</div>`);
// 렌더링된 페이지를 반환합니다.
ctx.body = page;
}
server/index.js
const Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa();
const render = require('./render');
// 경로가 / 일시에 index.html 을 전달하는게 아닌, 서버사이드 렌더링 작업을 합니다
app.use((ctx, next) => {
if(ctx.path === '/') return render(ctx);
return next();
});
// 파일을 요청 받으면 build 내부의 파일들을 반환합니다
app.use(serve(path.resolve(__dirname, '../build/')));
// 요청받은 경로가 파일들이 아니라면, 해당 경로를 위한 서버사이드 렌더링을 해줍니다
app.use(render);
app.listen(3001);
서버 시작하기
$ yarn start:server
데이터 로딩
의존 모듈 설치
$ yarn add redux react-redux redux-actions redux-pender axios
api.js 작성
src/lib/api.js
import axios from 'axios';
export const getUsers = () => axios.get('https://jsonplaceholder.typicode.com/users');
src/redux/modules/users.js
import { createAction, handleActions } from 'redux-actions';
import { pender } from 'redux-pender';
import * as api from 'lib/api';
// 액션 타입
const GET_USERS = 'users/GET_USERS';
// 액션 생성자
export const getUsers = createAction(GET_USERS, api.getUsers);
// 초기 상태
const initialState = {
data: []
};
export default handleActions({
...pender({
type: GET_USERS,
onSuccess: (state, action) => {
return {
data: action.payload.data
}
}
})
}, initialState);
src/redux/modules/index.js
import { combineReducers } from 'redux';
import users from './users';
import { penderReducer } from 'redux-pender';
export default combineReducers({
users,
pender: penderReducer
});
스토어가 서버쪽에서도 만들어져야하고
클라이언트에서도 만들어져야 하므로,
코드가 중복되니, 원하는 설정으로 스토어를 생성하는 함수를 준비
src/redux/configureStore.js
import { createStore, applyMiddleware, compose } from 'redux'
import penderMiddleware from 'redux-pender';
import modules from './modules';
const isDevelopment = process.env.NODE_ENV === 'development'; // 환경이 개발모드인지 확인합니다
// 개발모드에서만 리덕스 개발자도구 적용
const composeEnhancers = isDevelopment ? (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose) : compose;
const configureStore = (initialState) => {
const store = createStore(modules, initialState, composeEnhancers(
applyMiddleware(penderMiddleware())
));
// hot-reloading 를 위한 코드
if(module.hot) {
module.hot.accept('./modules', () => {
store.replaceReducer(modules);
});
}
return store;
}
export default configureStore;
리듀서가 수정되면,
스토어를 새로 만드는게 아니라,
리듀서를 갈아끼우는 작업을 거친다
configureStore 호출하는걸 Root 에서 하게 되면,
hot-reload 될 때마다 스토어가 새로 만들어짐
두가지 솔루션:
store.js 에서 미리 만들어두기
index.js 에서 만들고, Root 에게 props 로 전달
store.js 에서 미리 만들기
redux/store.js
import configureStore from './configureStore';
// 클라이언트에서만 사용됨
export default configureStore();
src/client/Root.js
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import App from 'shared/App';
import store from 'redux/store';
import { Provider } from 'react-redux';
const Root = () => (
<BrowserRouter>
<Provider store={store}>
<App/>
</Provider>
</BrowserRouter>
);
export default Root;
SSR 을 하면서 데이터로딩을 하는건,
딱히 정해진 방법이 없음
데이터 로딩 로직을 서버와 클라이언트 따로따로
or
한번 작성해서 로직을 공유
react-router-server 의 도구를 활용하여
최소한의 컴포넌트 복잡도로,
최대한의 코드 재사용,
리덕스와의 좋은 호흡
과 함께 SSR
(더 편한 방법이 존재 할 수 있음)
유저 목록을 불러와서 렌더링함
componentWillMount 에서 데이터 로딩 호출
componentWillMount 는 서버에서도 호출됨!
반면, componentDidMount 는 클라이언트에서만
react-router-dom 의 withDone 을 적용하면
props 로 전달되는 done() 이 호출 될 때까지
render 를 연기시킨다
componentWillMount → 데이터 로딩
→ done 호출 대기 → done 호출 → render
componentWillMount → 데이터 로딩
→ render → 데이터 로딩완료 → render
src/pages/Users.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as usersActions from 'redux/modules/users';
import { withDone } from 'react-router-server';
class Users extends Component {
componentWillMount() {
// 서버사이드에서도 데이터 로딩이 작동하기 위해서, 데이터 불러오는 작업을 componentWillMount 에서 호출합니다.
const { UsersActions, data, done } = this.props;
if(data.length !== 0) return false; // 데이터가 이미 존재하면 재요청 하지 않음
UsersActions.getUsers().then(done, done); // Promise 가 성공했을때, 혹은 실패했을때 done() 호출
}
render() {
const { data } = this.props;
// 유저 이름 목록을 생성합니다
const userList = data.map(
user => <li key={user.id}>{user.name}</li>
);
return (
<div>
<ul>
{userList}
</ul>
</div>
);
}
}
// withDone 으로 감싸주면, done 이 호출될때까지 렌더링을 미룹니다
export default withDone(connect(
(state) => ({
data: state.users.data
}),
(dispatch) => ({
UsersActions: bindActionCreators(usersActions, dispatch)
})
)(Users));
페이지 인덱스에 Users 추가
src/pages/index.js
export { default as Home } from './Home';
export { default as About } from './About';
export { default as Posts } from './Posts';
export { default as Post } from './Post';
export { default as Users } from './Users';
src/pages/index.async.js
import asyncRoute from 'lib/asyncRoute';
export const Home = asyncRoute(() => import('./Home'));
export const About = asyncRoute(() => import('./About'));
export const Post = asyncRoute(() => import('./Post'));
export const Posts = asyncRoute(() => import('./Posts'));
export const Users = asyncRoute(() => import('./Users'));
App 에서 라우트 설정
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About, Posts, Users } from 'pages';
import Menu from 'components/Menu';
class App extends Component {
render() {
return (
<div>
<Menu/>
<Route exact path="/" component={Home}/>
<Route path="/posts" component={Posts}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
<Route path="/users" component={Users}/>
</div>
);
}
}
export default App;
Menu 에서 보여주기
src/components/Menu.js - render - ul
<li><NavLink to="/users" activeStyle={activeStyle}>Users</NavLink></li>
react-router-server 의 renderToString:
비동기로 작동함,
withDone 을 기다려줌
렌더링 할 때마다 configureStore 로
새 스토어를 만든다
그리고, 렌더링 되고나면, store 상태가 바뀌는데,
이 상태를 getState() 로 불러와서
렌더링결과와 함께 반환
src/server/render.js
import React from 'react';
import { StaticRouter } from 'react-router';
import App from 'shared/App';
import configureStore from 'redux/configureStore';
import { Provider } from 'react-redux';
/* react-router-server 의 renderToString 은 비동기로 작동하며,
데이터 로딩도 관리해줍니다. */
import { renderToString } from 'react-router-server';
const render = async (location) => {
// 서버사이드에선, 매 요청마다 새 store 를 생성해주어야 합니다.
const store = configureStore();
const { html } = await renderToString(
<StaticRouter location={location}>
<Provider store={store}>
<App/>
</Provider>
</StaticRouter>
);
// 스토어와, 렌더링된 문자열 결과물을 반환합니다
return {
html,
state: store.getState()
};
}
export default render;
프로젝트 재 빌드
$ yarn build:server
$ yarn build
이젠, render 함수는 문자열만 리턴하는게 아닌,
객체안에 스토어 상태 + 문자열을 주는 Promise 반환
html 은 root 엘리먼트에,
state 는 <script></script> 를 추가하여
window.__PRELOADED_STATE__ 안에
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
return render(location).then(
({html, state}) => {
// html 을 넣어주고, state 를 window.__PRELOADED_STATE__ 값으로 설정
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${JSON.stringify(state)}</script>`);
ctx.body = page;
}
);
}
Node v7 이상 버전에선 async await 을 쓸 수있음!
server/render/index.js
// (...)
module.exports = async (ctx) => {
const location = ctx.path;
const { html, state } = await render(location);
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${JSON.stringify(state)}</script>`);
ctx.body = page;
}
서버 재시작
window.__PRELOADED_STATE__ = ...
이건 보안적으로 취약함.
태그를 닫고, 악성스크립트가 들어있는 문자열이 들어있다면.. 해킹위험성!
serialize-javascript
설치하기
$ yarn add serialize-javascript
JSON.stringify 대신
serialize-javascript
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
var serialize = require('serialize-javascript');
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
return render(location).then(
({html, state}) => {
// html 을 넣어주고, state 를 window.__PRELOADED_STATE__ 값으로 설정
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${serialize(state)}</script>`);
ctx.body = page;
}
);
}
configureStore 의 인자로
window.__PRELOADED_STATE__
src/redux/store.js
import configureStore from './configureStore';
// 클라이언트에서만 사용됨
export default configureStore(window.__PRELOADED_STATE__);
리액트 밖의 영역이기 때문에..
이런걸 해줘야 하지만
document.title = 'something';
var meta = document.createElement('meta');
meta.httpEquiv = "X-UA-Compatible";
meta.content = "IE=edge";
document.getElementsByTagName('head')[0].appendChild(meta);
react-helmet 이 대신 해줌
import React from "react";
import {Helmet} from "react-helmet";
class Application extends React.Component {
render () {
return (
<div className="application">
<Helmet>
<meta charSet="utf-8" />
<title>My Title</title>
<link rel="canonical" href="http://mysite.com/example" />
</Helmet>
...
</div>
);
}
};
이런식으로..
Helmet 을 통해 설정한 값은
DOM 트리에서 더 깊숙히 위치한것이 우선권을 가진다
<Parent>
<Helmet>
<title>My Title</title>
<meta name="description" content="Helmet application" />
</Helmet>
<Child>
<Helmet>
<title>Nested Title</title>
<meta name="description" content="Nested component" />
</Helmet>
</Child>
</Parent>
설치
$ yarn add react-helmet
사용
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About, Posts, Users } from 'pages';
import { Helmet } from "react-helmet";
import Menu from 'components/Menu';
class App extends Component {
render() {
return (
<div>
<Helmet>
<title>React Router & SSR</title>
</Helmet>
<Menu/>
<Route exact path="/" component={Home}/>
<Route path="/posts" component={Posts}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
<Route path="/users" component={Users}/>
</div>
);
}
}
export default App;
src/pages/About.js
import React from 'react';
import queryString from 'query-string';
import { Helmet } from 'react-helmet';
const About = ({location, match}) => {
const query = queryString.parse(location.search);
const detail = query.detail === 'true';
const { name } = match.params;
return (
<div>
<Helmet>
<title>{`About ${name ? name : ''}`}</title>
</Helmet>
<h2>About {name}</h2>
{detail && 'detail: blahblah'}
</div>
);
};
export default About;
src/server/render.js
import React from 'react';
import { StaticRouter } from 'react-router';
import App from 'shared/App';
import configureStore from 'redux/configureStore';
import { Provider } from 'react-redux';
/* react-router-server 의 renderToString 은 비동기로 작동하며,
데이터 로딩도 관리해줍니다. */
import { renderToString } from 'react-router-server';
import { Helmet } from 'react-helmet';
const render = async (location) => {
// 서버사이드에선, 매 요청마다 새 store 를 생성해주어야 합니다.
const store = configureStore();
const { html } = await renderToString(
<StaticRouter location={location}>
<Provider store={store}>
<App/>
</Provider>
</StaticRouter>
);
// helmet 정보를 가져옵니다
const helmet = Helmet.renderStatic();
// 스토어 상태와, 렌더링된 문자열 결과물, 그리고 helmet 정보를 반환합니다
return {
html,
state: store.getState(),
helmet
};
}
export default render;
index.html 에 치환자 넣기,
<title></title> 지우기
프로젝트 재 빌드
$ yarn build
$ yarn build:server
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
var serialize = require('serialize-javascript');
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
return render(location).then(
({html, state, helmet}) => {
// html 을 넣어주고, state 를 window.__PRELOADED_STATE__ 값으로 설정
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${serialize(state)}</script>`)
.replace('<meta helmet>', `${helmet.title.toString()}${helmet.meta.toString()}${helmet.link.toString()}`);
ctx.body = page;
}
);
}
public/index.html
<!doctype html>
<html lang="en">
<head>
<meta helmet>
(...)