From a3127947b8805397781bdf93585bfeafd10100bb Mon Sep 17 00:00:00 2001 From: Agung Surya Bangsa Date: Sun, 11 Jun 2017 00:45:17 +0700 Subject: [PATCH 1/2] Add example of how to prefetch ssr data --- public/index.html | 36 +++++++------ server/universal.js | 105 +++++++++++++++++++++++++++--------- src/ApiClient.js | 68 +++++++++++++++++++++++ src/actions/user.js | 10 ++++ src/containers/App.js | 20 ++++--- src/containers/FirstPage.js | 40 +++++++++++++- src/createMiddleware.js | 30 +++++++++++ src/index.js | 29 ++++++++-- src/reducers/user.js | 35 ++++++++++-- src/store.js | 4 +- 10 files changed, 320 insertions(+), 57 deletions(-) create mode 100644 src/ApiClient.js create mode 100644 src/createMiddleware.js diff --git a/public/index.html b/public/index.html index 74da614..9668526 100644 --- a/public/index.html +++ b/public/index.html @@ -1,10 +1,11 @@ - - - - - - Server Side Rendering - Create React App - - - -
{{SSR}}
- - - + + + \ No newline at end of file diff --git a/server/universal.js b/server/universal.js index 5de8773..1d85c96 100644 --- a/server/universal.js +++ b/server/universal.js @@ -2,42 +2,95 @@ const path = require('path') const fs = require('fs') const React = require('react') -const {Provider} = require('react-redux') -const {renderToString} = require('react-dom/server') -const {StaticRouter} = require('react-router-dom') +const { Provider } = require('react-redux') +const { renderToString } = require('react-dom/server') +const { StaticRouter } = require('react-router-dom') -const {default: configureStore} = require('../src/store') -const {default: App} = require('../src/containers/App') +const { default: configureStore } = require('../src/store') +import ApiClient from '../src/ApiClient'; +const { default: App } = require('../src/containers/App') +import FirstPage from '../src/containers/FirstPage' +import SecondPage from '../src/containers/SecondPage' +import NoMatch from '../src/components/NoMatch' + +import { matchPath } from 'react-router-dom' + +const routes = [ + { + path: '/', + exact: true, + component: FirstPage, + }, + { + path: '/second', + component: SecondPage, + }, + { + component: NoMatch + } +] module.exports = function universalLoader(req, res) { const filePath = path.resolve(__dirname, '..', 'build', 'index.html') - fs.readFile(filePath, 'utf8', (err, htmlData)=>{ + fs.readFile(filePath, 'utf8', (err, htmlData) => { if (err) { console.error('read err', err) return res.status(404).end() } - const context = {} - const store = configureStore() - const markup = renderToString( - - - - - - ) - - if (context.url) { - // Somewhere a `` was rendered - redirect(301, context.url) - } else { - // we're good, send the response - const RenderedApp = htmlData.replace('{{SSR}}', markup) - res.send(RenderedApp) + + // we'd probably want some recursion here so our routes could have + // child routes like `{ path, component, routes: [ { route, route } ] }` + // and then reduce to the entire branch of matched routes, but for + // illustrative purposes, sticking to a flat route config + const matches = routes.reduce((matches, route) => { + const match = matchPath(req.url, route.path, route) + if (match) { + matches.push({ + route, + match, + promise: route.component.fetchData ? + route.component.fetchData(match) : Promise.resolve(null) + }) + } + return matches + }, []) + + if (matches.length === 0) { + res.status(404) } + + const promises = matches.map((match) => match.promise) + + Promise.all(promises).then(data => { + // do something w/ the data so the client + // can access it then render the app + console.log('data', data[0]); + const context = {} + const client = new ApiClient(); + const store = configureStore(client) + const markup = renderToString( + + + + + + ) + + if (context.url) { + // Somewhere a `` was rendered + redirect(301, context.url) + } else { + // we're good, send the response + const RenderedApp = htmlData.replace('{{SSR}}', markup).replace('{{WINDOW_DATA}}', JSON.stringify(data[0])); + res.send(RenderedApp) + } + }, (error) => { + handleError(res, error) + }) }) } diff --git a/src/ApiClient.js b/src/ApiClient.js new file mode 100644 index 0000000..951f381 --- /dev/null +++ b/src/ApiClient.js @@ -0,0 +1,68 @@ +import superagent from 'superagent'; +// import config from '../config'; + +const methods = ['get', 'post', 'put', 'patch', 'del']; + +// function formatUrl(path) { +// const adjustedPath = path[0] !== '/' ? `/${path}` : path; +// if (__SERVER__) { +// // Prepend host and port of the API server to the path. +// // return `http://${config.apiHost}:${config.apiPort + adjustedPath}`; +// return `${config.apiHost}${adjustedPath}`; +// } +// // Prepend `/api` to relative URL, to proxy to API server. +// // return `/api${adjustedPath}`; +// return `${config.apiHost}${adjustedPath}`; +// } + +export default class ApiClient { + constructor(req) { + methods.forEach(method => { + this[method] = (path, { params, data, headers, files, fields } = {}, isExternal = false) => new Promise((resolve, reject) => { + let request; + // if (isExternal) { + request = superagent[method](path); + // request.set('X-Algolia-API-Key', '5f97501809f8c3b151b9e38979583138'); + // request.set('X-Algolia-Application-Id', 'UYC3TRXIO3'); + // } else { + // request = superagent[method](formatUrl(path)); + // request.set('Content-Type', 'application/json'); + // request.set('x-auth', 'kumkum'); + // request.withCredentials(); + // } + + if (params) { + request.query(params); + } + + // if (__SERVER__ && req.get('cookie')) { + // request.set('cookie', req.get('cookie')); + // } + if (headers) { + request.set(headers); + } + + // if (this.token) { + // request.set('Authorization', `Bearer ${this.token}`); + // } + + if (files) { + files.forEach(file => request.attach(file.key, file.value)); + } + + if (fields) { + fields.forEach(item => request.field(item.key, item.value)); + } + + if (data) { + request.send(data); + } + request.end((err, { body } = {}) => (err ? reject(body || err) : resolve(body))); + }); + }); + } + + // setJwtToken(token) { + // this.token = token; + // } +} diff --git a/src/actions/user.js b/src/actions/user.js index d42e16b..f12c172 100644 --- a/src/actions/user.js +++ b/src/actions/user.js @@ -1,4 +1,14 @@ import { SET, RESET } from '../types/user' +const LOAD = 'LOAD'; +const LOAD_SUCCESS = 'LOAD_SUCCESS'; +const LOAD_FAIL = 'LOAD_FAIL'; + +export function tes() { + return { + types: [LOAD, LOAD_SUCCESS, LOAD_FAIL], + promise: client => client.get('https://jsonplaceholder.typicode.com/posts/3') + } +} export function set(payload){ return { diff --git a/src/containers/App.js b/src/containers/App.js index 005ba70..5cb69be 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -5,17 +5,25 @@ import SecondPage from './SecondPage' import NoMatch from '../components/NoMatch' export default class App extends Component { - render(){ + render() { + const MyFirstPage = (props) => { + return ( + + ); + } return (
-

Server Side Rendering with Create React App v2

+ {/*

Server Side Rendering with Create React App v2

Hey, so I've rewritten this example with react-router v4

This code is on github: https://github.com/ayroblu/ssr-create-react-app-v2

-

Medium article: https://medium.com/@benlu/ssr-with-create-react-app-v2-1b8b520681d9

+

Medium article: https://medium.com/@benlu/ssr-with-create-react-app-v2-1b8b520681d9

*/} - - - + + +
) diff --git a/src/containers/FirstPage.js b/src/containers/FirstPage.js index 04fc4ec..fb39d34 100644 --- a/src/containers/FirstPage.js +++ b/src/containers/FirstPage.js @@ -6,7 +6,39 @@ import * as userActions from '../actions/user' import { Link } from 'react-router-dom' import './FirstPage.css' +import request from 'superagent'; + class FirstPage extends Component { + + // called in the server render, or in cDM + static fetchData(match) { + // going to want `match` in here for params, etc. + return new Promise((resolve, reject) => { + request.get('https://jsonplaceholder.typicode.com/posts/2').end((err, success) => { + if (err) { + reject(err); + } + resolve(success.body); + }); + }); + } + + state = { + // if this is rendered initially we get data from the server render + data: this.props.initialData || null + } + + componentDidMount() { + // if rendered initially, we already have data from the server + // but when navigated to in the client, we need to fetch + if (!this.state.data) { + this.constructor.fetchData(this.props.match).then(data => { + this.setState({ data }) + }) + } + this.props.userActions.tes(); + } + render() { const b64 = this.props.staticContext ? 'wait for it' : window.btoa('wait for it') return ( @@ -14,7 +46,13 @@ class FirstPage extends Component {

First Page

{`Email: ${this.props.user.email}`}

{`b64: ${b64}`}

- Second + Second
+

The text below is a prefetched SSR data:

+ {this.state.data && +

+ {this.state.data.id} - {this.state.data.title} +

+ } ) } diff --git a/src/createMiddleware.js b/src/createMiddleware.js new file mode 100644 index 0000000..0ee5a88 --- /dev/null +++ b/src/createMiddleware.js @@ -0,0 +1,30 @@ +export default function clientMiddleware(client) { + return ({ dispatch, getState }) => next => action => { + if (typeof action === 'function') { + return action(dispatch, getState); + } + + const { promise, types, ...rest } = action; // eslint-disable-line no-redeclare + if (!promise) { + return next(action); + } + + const [REQUEST, SUCCESS, FAILURE] = types; + next({ ...rest, type: REQUEST }); + + // const { auth } = getState(); + + // client.setJwtToken(auth.token || null); + + const actionPromise = promise(client, dispatch); + actionPromise.then( + result => next({ ...rest, result, type: SUCCESS }), + error => next({ ...rest, error, type: FAILURE }) + ).catch((error) => { + console.error('MIDDLEWARE ERROR:', error); + next({ ...rest, error, type: FAILURE }); + }); + + return actionPromise; + }; +} diff --git a/src/index.js b/src/index.js index fb6bed7..ff4cb7e 100644 --- a/src/index.js +++ b/src/index.js @@ -6,18 +6,39 @@ import { BrowserRouter } from 'react-router-dom' import configureStore from './store' import './index.css' import App from './containers/App' +import ApiClient from './ApiClient' + +import FirstPage from './containers/FirstPage' +import SecondPage from './containers/SecondPage' +import NoMatch from './components/NoMatch' + +const routes = [ + { + path: '/', + exact: true, + component: FirstPage + }, + { + path: '/second', + component: SecondPage, + }, + { + component: NoMatch + } +] // Let the reducers handle initial state -const initialState = {} -const store = configureStore(initialState) +const initialState = {}; +const client = new ApiClient(); +const store = configureStore(client, initialState) ReactDOM.render( - + -, document.getElementById('root') + , document.getElementById('root') ) diff --git a/src/reducers/user.js b/src/reducers/user.js index c5f6398..a91db51 100644 --- a/src/reducers/user.js +++ b/src/reducers/user.js @@ -1,15 +1,42 @@ import { SET, RESET } from '../types/user' +const LOAD = 'LOAD'; +const LOAD_SUCCESS = 'LOAD_SUCCESS'; +const LOAD_FAIL = 'LOAD_FAIL'; const initialState = { - email: 'user@example.com' + loaded: false, + loading: false, + email: 'user@example.com', + userId: 1, + id: 1, + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + body: "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit molestiae ut ut quas totam nostrum rerum est autem sunt rem eveniet architecto" } -export default function reducer(state=initialState, action) { +export default function reducer(state = initialState, action) { switch (action.type) { + case LOAD: + return { + ...state, + loading: true, + } + case LOAD_SUCCESS: + return { + ...state, + loaded: true, + loading: false, + ...action.result + } + case LOAD_FAIL: + return { + loaded: true, + loading: false, + error: action.error + } case SET: - return {...state, ...action.payload} + return { ...state, ...action.payload } case RESET: - return {...initialState} + return { ...initialState } default: return state } diff --git a/src/store.js b/src/store.js index 9891af9..02e55b6 100644 --- a/src/store.js +++ b/src/store.js @@ -1,14 +1,16 @@ import { createStore, applyMiddleware, compose } from 'redux' import reducers from './reducers' +import createMiddleware from './createMiddleware'; //import createLogger from 'redux-logger' //import createSagaMiddleware from 'redux-saga' //const logger = createLogger() //const sagaMiddleware = createSagaMiddleware() -export default function configureStore(initialState = {}) { +export default function configureStore(client, initialState = {}) { // Create the store with two middlewares const middlewares = [ + createMiddleware(client) // sagaMiddleware //, logger ] From f607964c7ab597e9ea5897bb43f07f0778dc5442 Mon Sep 17 00:00:00 2001 From: Agung Surya Bangsa Date: Sun, 11 Jun 2017 01:01:51 +0700 Subject: [PATCH 2/2] Tidy up --- src/ApiClient.js | 33 --------------------------------- src/createMiddleware.js | 4 ---- 2 files changed, 37 deletions(-) diff --git a/src/ApiClient.js b/src/ApiClient.js index 951f381..e269c02 100644 --- a/src/ApiClient.js +++ b/src/ApiClient.js @@ -1,51 +1,22 @@ import superagent from 'superagent'; -// import config from '../config'; const methods = ['get', 'post', 'put', 'patch', 'del']; -// function formatUrl(path) { -// const adjustedPath = path[0] !== '/' ? `/${path}` : path; -// if (__SERVER__) { -// // Prepend host and port of the API server to the path. -// // return `http://${config.apiHost}:${config.apiPort + adjustedPath}`; -// return `${config.apiHost}${adjustedPath}`; -// } -// // Prepend `/api` to relative URL, to proxy to API server. -// // return `/api${adjustedPath}`; -// return `${config.apiHost}${adjustedPath}`; -// } - export default class ApiClient { constructor(req) { methods.forEach(method => { this[method] = (path, { params, data, headers, files, fields } = {}, isExternal = false) => new Promise((resolve, reject) => { let request; - // if (isExternal) { request = superagent[method](path); - // request.set('X-Algolia-API-Key', '5f97501809f8c3b151b9e38979583138'); - // request.set('X-Algolia-Application-Id', 'UYC3TRXIO3'); - // } else { - // request = superagent[method](formatUrl(path)); - // request.set('Content-Type', 'application/json'); - // request.set('x-auth', 'kumkum'); - // request.withCredentials(); - // } if (params) { request.query(params); } - // if (__SERVER__ && req.get('cookie')) { - // request.set('cookie', req.get('cookie')); - // } if (headers) { request.set(headers); } - // if (this.token) { - // request.set('Authorization', `Bearer ${this.token}`); - // } - if (files) { files.forEach(file => request.attach(file.key, file.value)); } @@ -61,8 +32,4 @@ export default class ApiClient { }); }); } - - // setJwtToken(token) { - // this.token = token; - // } } diff --git a/src/createMiddleware.js b/src/createMiddleware.js index 0ee5a88..9f4f1e9 100644 --- a/src/createMiddleware.js +++ b/src/createMiddleware.js @@ -12,10 +12,6 @@ export default function clientMiddleware(client) { const [REQUEST, SUCCESS, FAILURE] = types; next({ ...rest, type: REQUEST }); - // const { auth } = getState(); - - // client.setJwtToken(auth.token || null); - const actionPromise = promise(client, dispatch); actionPromise.then( result => next({ ...rest, result, type: SUCCESS }),