리액트 라우터,
코드 스플리팅,
그리고 서버사이드 렌더링
1장
리액트 라우터 사용해보기
SPA란?
Single Page Application
페이지가 하나!
유저가 요청 할 때마다 HTML 을 새로 불러오고,
그때 그때 페이지가 새로고침된다
처음에 한번 불러오고
그 다음부터는 필요한 데이터만 불러와서
자바스크립트를 통하여 이를 화면에 반영한다
전통적인 웹 구조는,
유저 인터랙션이 많은 앱에선
적합하지 않음!
싱글페이지앱 이라고 해서,
한 종류의 화면만 있는건 아니다
예) 블로그
홈/ 포스트목록 /포스트 / 글쓰기 ...
화면에 따라
주소도 만들어주어야함
주소에 따라 원하는것을 보여주는것:
라우팅
리액트 내에 내장되어있지는 않음
페이스북에서 만든 공식 라우터도
딱히 없음
직접 만들어서 쓸 수도 있음
react-router
써드파티 라이브러리지만,
거의 공식 라우터나 마찬가지인 리액트 라우터
주소를 읽고, 필요한 정보들을 props 로 전달해주는 역할
SPA 의 단점
앱의 규모가 커지면, 자바스크립트 파일이 커짐
예) 블로그 포스트만 보고싶을 뿐인데,
블로그 홈, 글쓰기, 관리자 페이지 등에서 필요한 컴포넌트도 불러오게됨..
걱정마세요!
코드 스플리팅
유저 인터랙션이 별로 없는
정적인 사이트인 경우,
SPA는 적합하지 않을 수도 있음
구글 외의 검색엔진에선,
서버사이드렌더링을 하지 않으면,
검색 결과가 나타나지 않음
서버 사이드 렌더링
단점들보다, 장점들이 훨씬 큼
대안
Next.js
Gatsby.js
프로젝트 구성하기
1-1
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 를 설정하기 위한 라이브러리
파일 제거
- App.css
- App.js
- App.test.js
- logo.svg
디렉토리 생성
- src/components 컴포넌트들이 위치함
- src/pages 각 라우트들이 위치함
- src/client 브라우저 측에서 사용 할 최상위 컴포넌트
- src/server 서버측에서 사용 할 리액트 관련 코드
- src/shared 서버와 클라이언트에서 공용으로 사용되는 컴포넌트
- src/lib 웹 API 함수,및 코드스플리팅시 필요한 코드
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
Route 와 파라미터
1-2
기본 라우트 준비하기
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)
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
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;
URL 쿼리
리액트 라우터 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 는 문자열이다.
비교를 할 때에는, 타입을 동일하게 설정 후 비교!
라우트 이동하기
1-3
앱 내에서 다른 라우트로 이동 할 때
<a href="...">이거 쓰면 안됨</a>
(새로고침을 해버린다)
Link 컴포넌트
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;
NavLink 컴포넌트
설정한 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;
라우트 속의 라우트
1-4
리액트 라우터 v3 에서 v4 로 업데이트되면서
크게 바뀐 점
// RR v3
<Route path="foo" component={Foo}>
<Route path=":id" component={Bar}/>
</Route>
// 모든 Route 를 최상위에서 한꺼번에 지정해야됐었음
v4 부터는,
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.path
- match.url
location.pathname 현재 브라우저상의 위치
match Route 와 직접적으로 관계된 값
2장.
코드 스플리팅
(Code Splitting)
SPA의 단점
자바스크립트 번들파일에 어플리케이션에 필요한 모든 로직을 불러온다.
규모가 커지면, 용량이커지고... 초기로딩속도 또한 지연됨.
파일을 여러개로 나뉘어서 이 문제점을 해결하자!
코드 스플리팅
코드 스플리팅의 기본
2-1
한개의 파일에서 모두 불러오는것이 아닌,
라이브러리나 컴포넌트가 실제로 필요 해질 때 불러온다.
웹팩 / 바벨 환경설정 밖으로 빼내기
$ yarn eject
Vendor
프로젝트에서 전역적(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;
지금은, 준비작업만 했을 뿐..
비동기적 코드 불러오기: 청크 생성
2-2
코드 스플리팅의 꽃
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'))
라우트 코드 스플리팅하기
2-3
라우트를 기준으로 코드스플리팅을 해보자!
아까 봤던 코드를 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
웹팩에서 파일을 불러올때 다른 이름을 가진 파일을 불러올수있게함
production을 위한
웹팩 설정하기
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
코드 스플리팅은 성공!
TIP!
만약 라우트 말고 다른것들도 스플리팅 해서,
async.js 파일이 많아진다면..?
기본적으로는 async.js 파일을 불러오도록 하고,
개발서버 환경설정에서 다음 코드를 적용함
new webpack.NormalModuleReplacementPlugin(
/.*.async.js$/,
function(resource) {
resource.request = resource.request.replace('.async', '');
}
),
뒤에 .async 가 붙어있으면 이 부분을 지우기
react-hot-loader
2-4
코드가 변경되었을 때,
새로고침 하지 않고 바뀐 부분만 빠르게 교체해주는 도구
필수는 아니지만, 앱의 규모가 커지면 코드가 수정될때마다
딜레이가 발생....
개발자 경험을 향상하자!
$ 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 가 제대로 작동하지 않기 때문에, 개발환경에서는 코드스플리팅을 안하는것이 좋음
3장
서버사이드 렌더링
대망의 서버사이드 렌더링!
앞으로 개발하면서, 서버사이드렌더링을
하지 않을 확률이 높음
필수작업은 아니기때문..
하지만, 필요해질수도 있는데..
서버사이드 렌더링을 처음 하게 될 땐,
엄청난 삽질이 예약되어있다.
나중에 그 삽질을 덜기 위해서,
오늘 서버사이드 렌더링의 흐름을 알아보자
SSR을 통해 얻을 수 있는 이점
검색엔진 최적화
자바스크립트 위주의 프로젝트는,
자바스크립트 엔진이 돌아가지 않으면
원하는 정보를 표시해주지 않음
내용이 비어있다!
보통 검색엔진 크롤러에는 자바스크립트엔진이 없음..
구글은 있음
Facebook / Kakao 크롤러도 마찬가지
SNS 공유를 위한거면, meta 태그만으로도 충분 함
(meta 태그는 주소에 따라 서버에서 넣어주어야함)
성능의 개선
자바스크립트가 로드 되기도 전에, 첫 렌더링 된 html 을 클라이언트에게
전달하기 때문에 초기 로딩속도를 많이 줄여줌
자바스크립트가 로딩되기전에도 유저가 컨텐츠 이용 가능!
단점, 또한 존재한다
프로젝트의 복잡도
성능의 악화 가능성
?
초기 렌더링을 서버에서 해주니,
그 부담을 서버가 가져감
서버 사양이 좋지 않다면, SSR은 부적합
비동기식 렌더링
최적화
- 개인화된 데이터는 서버사이드 렌더링 피하기
- 모든유저에게 같은 형식으로 보여주는 뷰는 캐싱
또 다른 대안
meta 태그만 넣어주기
서버 사이드 렌더링은
백엔드 공부 이후 본격적으로 알아봅시다!
Koa 사용하기
3-1
Koa?
Express.js 를 만들었던 개발팀이,
새로 만든 웹 프레임워크
훨씬 가벼움
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 해서 사용하는것.
서버 수정할때마다 새로 빌드 할 필요 없어서 좋음
서버사이드 렌더링
준비하기
3-2
서버용 엔트리 생성
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'
}
);
// (...)
NPM 스크립트 생성
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 만들어졌는지 체크
서버쪽 코드 작성하기
3-3
서버사이드 렌더링
미들웨어 작성
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
올 ㅋ
데이터 로딩
Redux 적용하기
3-4
의존 모듈 설치
$ 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');
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
});
configureStore.js 만들기
스토어가 서버쪽에서도 만들어져야하고
클라이언트에서도 만들어져야 하므로,
코드가 중복되니, 원하는 설정으로 스토어를 생성하는 함수를 준비
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;
데이터 로딩
3-5
SSR 을 하면서 데이터로딩을 하는건,
딱히 정해진 방법이 없음
데이터 로딩 로직을 서버와 클라이언트 따로따로
or
한번 작성해서 로직을 공유
react-router-server 의 도구를 활용하여
최소한의 컴포넌트 복잡도로,
최대한의 코드 재사용,
리덕스와의 좋은 호흡
과 함께 SSR
(더 편한 방법이 존재 할 수 있음)
Users 라우트 생성
유저 목록을 불러와서 렌더링함
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 라우트 적용
페이지 인덱스에 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>
데이터 로딩 (서버)
3-6
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__);
react-helmet 을 통한
페이지 헤드 정보 설정
3-7
페이지 제목과 메타태그 설정은 SEO 에 매우 중요함!
리액트 밖의 영역이기 때문에..
이런걸 해줘야 하지만
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;
About 에도 헤드 설정
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;
서버사이드
리액트 헬멧
Helmet.renderStatic()
- base
- bodyAttributes
- htmlAttributes
- link
- meta
- noscript
- script
- style
- title
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
Helmet 정보
html 에 삽입하기
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>
(...)
수고하셨습니다
react-router-advanced
By Minjun Kim
react-router-advanced
- 2,606