æ¥åã§ä½¿ããç°¡åãªSSR + SPA ã®ãã³ãã¬ã¼ããå ¬éãã
ä¹ ãã¶ãã®ããã°ã§ãã
ããNode.jsã®äººã¨æãããã¡ã§ãããæ®æ®µã¯Node.jsã§ã®ããã¯ã¨ã³ãéçºã¯ãã¡ããã§ããReactãVueãæ¸ãã¦ãã¾ãã®ã§ããã¾ã«ã¯ããã³ãã¨ã³ããã¿ãæ稿ãããã¨æãã¾ãã
- 主ãªæè¡ã¹ã¿ãã¯
- 注æ
- Server
- Client
- Misc
- ãããã«
ãªãã¸ããªã«ããã³ã¼ãè¦ãã»ããæ©ãã¨æãã¾ãã®ã§ãããã§ã¯æ³¨æç¹çãåæãã¦ãããããªã¨æãã¾ãã
主ãªæè¡ã¹ã¿ãã¯
dependencies
- react@16
- react-router-dom@4
- react-helmet@5
- react-loadable@5
- redux@4
- [email protected]
- styled-components@3
- express@4
- dotenv@6
devDependencies
- typescript@3
- [email protected]
- jest@23
- ts-node@
- webpack@4
- workbox@3
注æ
ä»åã¯ãèªåã®å¥½ãå«ããå«ã以ä¸ã®ãã¨ãå°å ¥ãã¾ããã§ããã
- Atomic Design
- ãããªç°¡åãªã³ã¼ãã«5層ããããªã
- decorators
@connect
ãæ®æ®µä½¿ããªãã®ã¨ã¾ã å®é¨ä¸ãªãã
bindActionCreators
- éä¸ã§ã«ã¹ã¿ãã¤ãºãå¿ è¦ã«ãªã£ã¦ã¯ãã使ã£ã¦ã¦å¯¾å¿ã§ããªããªã£ã¦çµå±æ¶ããã(çµé¨è«)
- ãµã³ãã«ã³ã¼ããªã®ã§ãæä½éå¿ è¦ãªãã®ãããããªã
Server
Server Side Renderingãè¡ã
ä¸çªãã¢ã«ãªãé¨åã§ãã
ãã®ä¸ã§ã¯ãSPAæã¨åæ§ã«åããããããã³ãã¨ã³ãã®ã³ã¼ãã使ã£ã¦å®è¡ãã¾ãã
// [server] renderer.ts const store = configureStore(); const sheet = new ServerStyleSheet(); // styled-componentsç¨ const jsx = ( <Provider store={store}> <StaticRouter location={req.url} context={{}}> <div id="root"> <Router /> </div> </StaticRouter> </Provider> ); // sagaã®å¦çãåæ¢ããã¨ããã解決ããã store .runSaga(rootSaga) .done.then(() => { const preloadedState = JSON.stringify(store.getState()); const helmetContent = Helmet.renderStatic(); const meta = ` // helmetããheadã«å ¥ããæ å ±ãåå¾ãã ${helmetContent.title.toString()} ${helmetContent.meta.toString()} `.trim(); const style = sheet.getStyleTags(); const body = renderToString(jsx); // sagaã«ããæ´æ°ãããstoreã使ãå度ã¬ã³ããªã³ã°ãã res.send(renderFullPage({ meta, assets, body, style, preloadedState })); // htmlçæ }) .catch((e: Error) => { res.status(500).send(e.message); }); // redux-sagaã®èµ·ååã³éåæå¦çã¨styled-componentsãstyleãæãä½æ¥ãããã renderToStaticMarkup(sheet.collectStyles(jsx)); // forkã§åãã¦ããredux-sagaãæ¢ãã(ããããªãã¨ãã£ã¨èµ·åãã¦ãã¦ã¬ã¹ãã³ã¹ãè¿ããªã) store.close();
https://github.com/hiroppy/ssr-sample/blob/master/src/server/controllers/renderer/renderer.tsx
// [client] configureStore.ts const sagaMiddleware = createSagaMiddleware(); export const configureStore = (preloadedState: Object = {}) => { const enhancer = createEnhancer(); const store: Store & { runSaga: SagaMiddleware<typeof rootSaga>['run']; // åã追å close: () => void; } = createStore(rootReducer, preloadedState, enhancer); sagaMiddleware.run(rootSaga); store.runSaga = sagaMiddleware.run; // renderer.tsã§å¼ã¹ãããã«è¿½å store.close = () => { // renderer.tsããsagaãæ¢ããå½ä»¤ãéãã¡ã½ããã追å store.dispatch(END); // forkããã¦ããsagaãæ¢ãã¦renderer.tsã®store.runSaga(rootSaga).doneã解決ããã };
https://github.com/hiroppy/ssr-sample/blob/master/src/client/store/configureStore.ts
SSRæã«ã¯<html>
, <head>
ãå«ãHTMLãçæããªãã¨ãããªãããæååã¨ãã¦æåã§çæããå¿
è¦ãããã¾ãã
ã¾ããreact-helmetã¯SSRæã«ã¯çæãããªããããæåã§meta
ãªã©ãæ½åºããå¿
è¦ãããã¾ãã
ãã以å¤ã¯ãã¯ã©ã¤ã¢ã³ãå´ã®ã³ã¼ããå©ç¨ãã¾ãã
htmlçæã³ã¼ã ã¯ãã¡ãã
以ä¸ãã¯ã©ã¤ã¢ã³ãåæ§ã«è¡ããã¨ã¨ãªãã¾ãã
redux-sagaã使ã£ã¦éåæå¦ç
renderç³»ã®ã¡ã½ããã使ã£ã¦ãredux-sagaãèµ·åããã¾ãã
ããã¾ã§ãç®çãããã¯ã§ããå·®åæ´æ°ã«é¢ããå¦çãå¿ è¦ãªããããrenderToString
ãã軽éãªrenderToStaticMarkup
ã使ãã¾ããå¦çãçµãã次第ãredux-sagaã¸æ¢ããå½ä»¤ãéãã¾ãã(
store.close()
ãçµç±ããEND
ã¢ã¯ã·ã§ã³ãçºè¡)store.runSaga(rootSaga).done
ã解決ãããããããã®ä¸ã§æ´æ°ãããstoreã®ãã¼ã¿ãæ½åºãã¯ã©ã¤ã¢ã³ãã«åæã®stateããããããã«<script>
ã¿ã°ã«åãè¾¼ã¿ã¾ããstoreãæ´æ°ããããããå度ã¬ã³ããªã³ã°(
renderToString
)ãè¡ããstoreã®çµæãåæ ãããHTMLãçæãã¾ãã
ä¸åç®ã®ã¬ã³ããªã³ã°ã§ã¯ãã¡ããstoreã®å¤ã¯ç©ºãªã®ã§ãã®æ»ãå¤ã®HTMLã«ã¯ãã¼ã¿ã¯åå¨ãã¾ããã
ã¤ã¾ããããã¯ç¨ã¨HTMLçæç¨ã®ã¬ã³ããªã³ã°ãæä½2åã¯å¿ è¦ã¨ãããã¨ã§ãã
react-helmetã使ã£ã¦headã¿ã°ãçæ
ã¯ã©ã¤ã¢ã³ãã§è¡ãã¨ãã¨ç°ãªã£ã¦ããµã¼ãã¼ã§ã¯headã¿ã°ã«å¯¾ãã¦åçå·®ãè¾¼ã¿ãã§ããªãããã¬ã³ããªã³ã°ãããæåã§htmlã«å·®ãè¾¼ãå¿
è¦ãããã¾ãã
ãªã®ã§ãredux-sagaã®ä¸åç®ã®ã¬ã³ããªã³ã°ã«ä¾¿ä¹ãã¦ãreact-helmetã®è¦ç´ ãæ½åºãã¾ãã
redux-sagaã®è§£æ±ºå¾ãããã§å¿
è¦ãªã¿ã°ãåå¾(helmetContent.title.toString()
)ãHTMLã®çæé¢æ°ã¸æµãã¾ãã
styled-componentsã§ä½¿ããã¦ããcssãæ½åº
redux-sagaã®ä¸åç®ã®ã¬ã³ããªã³ã°ã«ä¾¿ä¹ãã¦ãstyled-componentsã§æ¸ãããã³ã³ãã¼ãã³ãã®cssãæ½åºãã¾ãã
ã¯ã©ã¤ã¢ã³ããHTMLãåãåã£ãæã«ã¬ã³ããªã³ã°ãããDOMã®cssããªãã¨è¦ãç®ãä¸è´ããªãããã§ãã
ããã§æ½åºããstyle
ã¿ã°ãHTMLã®çæé¢æ°ã¸æµãhead
ã¸æ¿å
¥ãã¾ãã
Development
Hot Module Replacementãæå¹ã«ãã
éçºæã«ã¯HMRãæå¹åããããã«ãwebpackã®ãã«ããNodeãµã¼ãã¼èµ·åæã«è¡ãã¾ãã(ã¾ããããã³ãã¨ã³ãã§ã¯hydrate
ã§ã¯ãªããrender
ã«ãã¾ã â»å¾è¿°)
if (process.env.NODE_ENV !== 'production') { const webpack = require('webpack'); const webpackHotMiddleware = require('webpack-hot-middleware'); const webpackDevMiddleware = require('webpack-dev-middleware'); const config = require('../../webpack.config'); const compiler = webpack(config); app.use(webpackHotMiddleware(compiler)); app.use( webpackDevMiddleware(compiler, { publicPath: config.output.publicPath }) ); }
https://github.com/hiroppy/ssr-sample/blob/master/src/server/server.ts#L15-L28
Production
ä»åã¯ãwebpackããããã«ts-nodeã§éçºãæ¬çªãèµ·åããã¾ãã
Manifestãèªã¿è¾¼ã
æ¬çªç°å¢ã§ã¯ããã¡ã¤ã«åã«ããã·ã¥ãå«ããããã³ã¢ã³ã¼ãã¯manifestãã¡ã¤ã«ãåç
§ãã¯ã©ã¤ã¢ã³ãã«è¿ã<script>
ãçæãã¦ããã¾ãã
ä»ã®èªåã®ã³ã¼ãã§ã¯ãèµ·åæã«èªã¿è¾¼ãããã«ãã¦ãã¾ããããµã¼ãã¼ãåèµ·åãããããªããã°ãªã¯ã¨ã¹ããæ¥ãæã«fsã使ã£ã¦manifestãèªã¿è¾¼ãããã«å¤ããã°ããã§ãã
åºæ¬ããµã¼ãã¼ã®ã³ã¼ããã¯ã©ã¤ã¢ã³ãã®ã³ã¼ãã¹ã£ãããªã®ã§èµ·åæã«ãªãã¨æãã¾ãããã
const assets = (process.env.NODE_ENV === 'production' ? (() => { const manifest = require('../../../dist/manifest'); return [manifest['vendor.js'], manifest['main.js']]; })() : ['/public/main.bundle.js'] ) .map((f) => `<script src="${f}"></script>`) .join('\n');
Clusterã使ã
è² è·åæ£ã®ããã«Clusterãè¡ãã¾ãã
const numCPUs = cpus().length; if (cluster.isMaster) { [...new Array(numCPUs)].forEach(() => cluster.fork()); // ããè½ã¡ããåèµ·åãããã cluster.on('exit', (worker, code, signal) => { console.log(`Restarting ${worker.process.pid}. ${code || signal}`); cluster.fork(); }); } else { runServer(); }
https://github.com/hiroppy/ssr-sample/blob/master/src/server/index.ts#L13-L25
Benckmark
SSRãããµã¼ãã¼ã®ããã©ã¼ãã³ã¹ãã¥ã¼ãã³ã°ãå¿
è¦ã¨ããå ´é¢ã¯ããã¨æãã¾ãã
autocannonã使ããserverå´ã®ãã³ããã¼ã¯ãåãã¾ãã
ããã§ReactããéçãªHTMLãçæãè¿ãã¾ã§ã®Latencyãè¨æ¸¬ãã¾ãã
> autocannon http://localhost:3000 -c100 Running 10s test @ http://localhost:3000 100 connections Stat Avg Stdev Max Latency (ms) 153.89 138.41 2479.02 Req/Sec 643.5 86.29 758 Bytes/Sec 1.64 MB 214 kB 1.94 MB 6k requests in 10s, 16.5 MB read
ãã£ã¨è©³ç´°ã«ç¥ãããå ´åã¯perf_hooks
ã使ããrenderToString
ã®å®è¡æéã測ããã¨ãå¯è½ã§ãã
å¯è¦åãã
clinicã使ã詳細ãªæ å ±ãå¯è¦åãã¦ç¢ºèªãããã¨ãå¯è½ã§ãã
ã¤ãã³ãã«ã¼ãã®æ å ±ãflameã®æ å ±ã表示ã§ããããªããããããã便å©ãªã®ã§ãªã¹ã¹ã¡ã§ãã
https://github.com/hiroppy/ssr-sample/blob/master/package.json#L14-L16
renderToNodeStreamããªã使ããªããï¼
gistã«è²¼ããã以ä¸ã®ã³ã¼ãã¯renderToNodeSteam
ã§æ¸ããã³ã¼ãã§ããhttps://gist.github.com/hiroppy/1c89d73a12073bad0c187aaab4ca92c2
äºãã«æååãæãã§æ¸ãã®ãå人çã«å¥½ããããªãã®ã¨ãreact-helmetãã¾ã steamã«å¯¾å¿ãã¦ããªã(PR: nfl/react-helmet#296)ã®ã主ãªçç±ã§ãã
res.write('<html><head><title>Test</title></head><body>'); const stream = sheet.interleaveWithNodeStream(renderToNodeStream(jsx)); stream.pipe(res, { end: false }); stream.on('end', () => res.end('</body></html>'));
èªåçã«ã¯ãããã©ã¼ãã³ã¹ããã°ããªã£ã¦ãããèããç¨åº¦ã®æ¸©åº¦æã§ãã
Client
UIæ§æ
PWAã¨åæ§ã«ãApp Shellã¨Contentã«åãã¦ãã¾ãã
次ã®ãã¼ã¸ã«è¡ã£ãæã«åã®ãã¼ã¸ã¨åãApp Shellã®å ´åã¯Contentã ããã¬ã³ããªã³ã°ãã¾ãã(connect
ãã¦ããå ´åã¯ãã®ç®æã ãã¬ã³ããªã³ã°ãã¾ã)
ä»åã¯ãheaderãApp Shellã§ãããContentã¯react-routerã§é¸ã°ããdynamic importããã¦ããã³ã³ãã¼ãã³ãã§ãã
export const Main = ({ children }: Props) => ( <React.Fragment> <Header /> <Container>{children}</Container> {/* ãã®childrenã¯react-routerããæ¥ãcontent*/} </React.Fragment> );
https://github.com/hiroppy/ssr-sample/blob/master/src/client/components/templates/Main/Main.tsx
// App ãheaderçãæã£ã¦ãã export const Router = () => ( <App> <Switch> <Route exact path="/" component={LoadableTop} /> <Route path="/orgs/:org" component={LoadableOrgs} /> </Switch> </App> );
https://github.com/hiroppy/ssr-sample/blob/master/src/client/Router/Router.tsx
Meta
metaã¿ã°ã®æ±ºå®ã¯ãAtomic Designã§ããpages
ã§è¡ãã¾ãã
ããã¯SSRæã«ã使ãããããå
±éåãããå¦çã§ãã
export const Top = () => ( <React.Fragment> <Head title="top" /> <h1>Top</h1> </React.Fragment> );
https://github.com/hiroppy/ssr-sample/blob/master/src/client/components/pages/Top/Top.tsx
render vs hydrate
éçºæã«ã¯ãããã³ãã¨ã³ãã®ã³ã¼ãå¤æ´ãå¤ããµã¼ãããä½ãããHTMLã¨ä¸è´ããªãå ´é¢ãå¤ããªããããhydrate
ã¯ä½¿ãã¾ããã
æ¬çªã§ã¯ãhydrate
ã使ãã¾ãã
const renderMethod = module.hot ? ReactDOM.render : ReactDOM.hydrate;
https://github.com/hiroppy/ssr-sample/blob/master/src/client/index.tsx#L14
redux-sagaã®ãã¹ã
èªåã®å ´åã¯redux-saga-test-planã使ããã¹ãã®ã·ããªãªãä½ãã¾ãã
ã¾ããã³ã¼ãç½®æã¨ãã¦proxyquireãrewireãHTTPãµã¼ãã¼ã®mockã¨ãã¦nockã使ãã¾ãã
ä¾ãã°ãä»åã®ãããªAPIãå©ããã¹ãã¯ä»¥ä¸ã®ããã«æ¸ãã¾ãã
const initialState = { name: 'name', repos: [] }; test('should take on the FETCH_REPOS action', () => { nock('https://api.github.com') // https://api.github.com/orgs/test/repos ã®è¿ãå¤ãè¨å®ãã .get('/orgs/test/repos') .reply(200, [ { forks_count: 100, name: 'foo', html_url: 'url', language: 'lang', open_issues_count: 200, stargazers_count: 300, watchers_count: 400 } ]); return expectSaga(orgsProcess) .withState(initialState) .put({ type: 'FETCH_REPOS_SUCCESS', payload: { name: 'test', repos: [ { forksCount: 100, name: 'foo', url: 'url', language: 'lang', issuesCount: 200, stargazersCount: 300, watchersCount: 400 } ] } }) .dispatch({ type: 'FETCH_REPOS', payload: { org: 'test' } }) .run(); });
https://github.com/hiroppy/ssr-sample/blob/master/src/client/sagas/orgs.test.ts
Misc
Dotenv
docker-composeã§èµ·åããæãæ¬çªãããã¤æã«ã¯ã.env
ã使ã£ã¦ç°å¢å¤æ°ãå
¥ãããã¨ãå¤ãã¨ãæãã¾ãã
ä»åã®ãµã³ãã«ã§ã¯ãã¯ã©ã¤ã¢ã³ãå´ã§ã¯dotenv-webpackããµã¼ãã¼å´ã§ã¯webpackãéããªãããdotenvã使ãå
±éã®.env
ãèªã¿è¾¼ã¿ã¾ãã
https://github.com/hiroppy/ssr-sample/blob/master/webpack.config.js#L36-L39
Dynamic Import
tsconfig
clientã¨serverã®tsconfigãåããå¿
è¦ãããã¾ãã
"module": "commonjs",
ã¨æå®ããå ´åã
Promise.resolve().then(function () { return require('./foo'); });
ã¨ç½®æãã¦ãã¾ããwebpackã§ãã£ã³ã¯ã¨ãã¦åããªãããã§ãã
webpackå´ã«dynamic importã¨ãããã¨ãç¥ããããããesnext
ãæå®ããå¤æããããªãããã«ããå¿
è¦ãããã¾ãã
ããããesnext
ã¨æ¸ãã¨ç¡å¤æã«ãªãããNode.jsã§ã¯ESMã®ã·ã³ã¿ãã¯ã¹ãåå¨ããªãããããµã¼ãã¼å´ãã¨ã©ã¼ã¨ãªãã¾ãã(ã¤ã¾ãcommonjs
ã§ãªãã¨ãã¡)
æ
ã«ã以ä¸ã®ããã«åããå¿
è¦ãããã¾ãã
// server { "extends": "tsconfig.base.json", "compilerOptions": { "module": "commonjs", "moduleResolution": "node" } } // client { "extends": "tsconfig.base.json", "compilerOptions": { "module": "esnext", "moduleResolution": "node" } }
ä»Node.jsã§ã¯ESMãå®é¨ä¸ã§åãã¾ããããããæ¬çªã«å
¥ãã°ã£ã¦ãã話ã§ããªãããªããã¨ããã¨Node.jsã«ããã¦ESMã¯æ¡å¼µåã.mjs
ã§ããããã§ãã
ãªã®ã§ãtså´ãåããã¡ã¤ã«ã®æ¡å¼µåã.mjs
ã«ããªãã¨ãããªããä¸çç¸ã§ã¯ãããªãããã«æãã¾ãã
çµè«ã¨ãã¦ã¯ãTypeScript使ã£ã¦ã¦webpackã§dynamic importããããã¡ã¤ã«ããã£ã³ã¯ã¨ãã¦åãããå ´åã¯ãmodule: esnext
ã«ãã¾ãããï¼
react-lodable
æ´»çºã§ã¯ãªããä»ããé¸ã¶ã®ã¯ãã¾ãè¯ããªãã¨æãã¾ãã
ã¾ããissueããªãã®ãæ
å ±éå°ãªããå人çã«ã¯ã¤ããã§ãã
react-loadableãwebpack4対å¿ããã¦ãªãããwillMount使ã£ã¦è¦åã§ãããPRã¯ãªããã¯ãã¼ãºããããã§ãã¾ãæªæ¥ãæãããªããhttps://t.co/9WIp5v1YJV
— hiroppyð¶ (@about_hiroppy) 2018å¹´8æ4æ¥
åé¡ç¹
webpack4ã«å¯¾å¿ãã¦ããªã
Migrate to webpack@4 API by 7rulnik · Pull Request #110 · jamiebuilds/react-loadable · GitHub
ãªããwebpack4ã®PRã®ä¼è©±ãspanã¨ãã¦ããã¯ããã
çµæ§è´å½çã ã¨æãã¾ãããwebpack4ã ã¨SSRæã«Loadable.Capture
ããdynamic importã§ä½¿ãããã¹ã¯ãªããåãåå¾ã§ããªãã§ãã
Lodable.Capute
ãå®è¡ããªãã¦ãHTMLçã«ã¯dynamic importãå±éãã¦ãããã®ã§SEOçã«ã¯åé¡ããã¾ããã
HTMLã«scriptã¿ã°ãåãè¾¼ã¾ãªãå ´åããã§ã«èªã¿è¾¼ã¿æ¸ã¿ã®HTMLã«å¯¾ãã¦ãclientå´ã¯èªç¥ãã¦ããªããdynamic importããããã¡ã¤ã«ããµã¼ãã¼ã¸åå¾ãã«ããããããã§ã«è¡¨ç¤ºããã¦ããã®ã«ãã¼ãã£ã³ã°ã«UIãåãæ¿ãã¦ãã¾ãã®ãåé¡ã¨ãªãã¾ãã
componentWillMount
çã使ã£ã¦ããããè¦åãã§ã
è¦åãåºã¾ãã
åå®ç¾©ããããã
render
ã¯optionalãªã®ã«ãç¾å¨ã¯å¿
é ã§ãã
æ¬æ¥ãOptionsWithoutRender
ã«è¡ãã¹ããªã®ã«OptionsWithRender
ãåªå
ãããã®ãåé¡ã§ãã(PRãåºãå¿
è¦ãã)
ãªã®ã§ç¾å¨ã¯ã以ä¸ã®ããã«åå®ç¾©ãè¡ã£ã¦ãã¾ãã
export const LoadableOrgs = Loadable({ loader: () => import(/* webpackChunkName: "Orgs" */ '../containers/Orgs').then(({ Orgs }) => Orgs), loading: () => <div>loading ...</div> } as Loadable.OptionsWithoutRender<unknown>);
https://github.com/hiroppy/ssr-sample/blob/master/src/client/Router/Routes.tsx
loadable-components
APIãã·ã³ãã«ã§ãããããããããã好ãã§ãã
ãã ãbabelãã©ã°ã¤ã³ã«ä¾åãã¦ãããloadable-components/babel
ã使ããªãã¨SSRã¯å®è¡ã§ããªãããå¿
é ã§ãã
ããã ãã©ãã«ããã¦ã»ãããã
ãããã«
ã¾ã ãdynamic importå¨ããèªåã®ä¸ã§ä½ãããã¡ã¯ãã«ãããæ©ãã§ãã¾ãã(ã¨ãã£ã¦ãããã®ã¾ã¾ããã¨èªç¶ã¨loadable-componentsãããªã)
ããæ´ã«æ¹åç¹ãããã°PRãå¾
ã¡ãã¦ããã¾ãð
ã¾ãä½ã質åãããã¾ããããã¤ãã£ãã¼ã¾ã§ã©ããð