From f05849d003719279ec760b054bde1fef38ee7f48 Mon Sep 17 00:00:00 2001 From: Addy Osmani Date: Thu, 23 Jun 2016 12:27:57 +0100 Subject: [PATCH] Add support for server-side rendering Top Stories & Comments (#47) * index.ejs: set base href to fix asset paths for SSR views * package.json: add object-assign shim * App.js: support prebooted HTML / hydration of content * Adds hn-server-fetch using unofficial Firebase API The official Firebase API (https://github.com/HackerNews/API) requires multiple network connections to be made in order to fetch the list of Top Stories (indices) and then the summary content of these stories. Directly requesting these resources makes server-side rendering cumbersome as it is slow and ultimately requires that you maintain your own cache to ensure full server renders are efficient. To work around this problem, we can use one of the unofficial Hacker News APIs, specifically https://github.com/cheeaun/node-hnapi which directly returns the Top Stories and can cache responses for 10 minutes. In ReactHN, we can use the unofficial API for a static server-side render and then 'hydrate' this once our components have mounted to display the real-time experience. The benefit of this is clients loading up the app that are on flakey networks (or lie-fi) can still get a fast render of content before the rest of our JavaScript bundle is loaded. * server.js: add support for SSR render of top stories and comments * hn-server-fetch: add support for SSR nested comments * hn-server-fetch: remove duplicate time indication * Stories.js: display spinner for pages > 0 --- hn-server-fetch.js | 100 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 9 ++-- server.js | 49 ++++++++++++++++++++-- src/App.js | 12 +++++- src/Stories.js | 2 +- src/views/index.ejs | 2 +- 6 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 hn-server-fetch.js diff --git a/hn-server-fetch.js b/hn-server-fetch.js new file mode 100644 index 0000000..3872e61 --- /dev/null +++ b/hn-server-fetch.js @@ -0,0 +1,100 @@ +require('isomorphic-fetch') + +/* +The official Firebase API (https://github.com/HackerNews/API) requires multiple network +connections to be made in order to fetch the list of Top Stories (indices) and then the +summary content of these stories. Directly requesting these resources makes server-side +rendering cumbersome as it is slow and ultimately requires that you maintain your own +cache to ensure full server renders are efficient. + +To work around this problem, we can use one of the unofficial Hacker News APIs, specifically +https://github.com/cheeaun/node-hnapi which directly returns the Top Stories and can cache +responses for 10 minutes. In ReactHN, we can use the unofficial API for a static server-side +render and then 'hydrate' this once our components have mounted to display the real-time +experience. + +The benefit of this is clients loading up the app that are on flakey networks (or lie-fi) +can still get a fast render of content before the rest of our JavaScript bundle is loaded. + */ + +/** + * Fetch top stories + */ +exports.fetchNews = function(page) { + page = page || '' + return fetch('http://node-hnapi.herokuapp.com/news' + page).then(function(response) { + return response.json() + }).then(function(json) { + var stories = '
    ' + json.forEach(function(data, index) { + var story = '
  1. ' + + '
    ' + data.title + ' ' + + '(' + data.domain + ')
    ' + + '
    ' + data.points + ' points ' + + 'by ' + data.user + ' ' + + ' | ' + + '' + data.comments_count + ' comments
    ' + '
  2. ' + stories += story + }) + stories += '
' + return stories + }) +} + +function renderNestedComment(data) { + return '
' + + '
' + + '
' + + '
[–] ' + + '' + data.user + ' ' + + ' ' + + 'link
' + + '
' + + '
' + data.content +'
' + + '

reply

' + + '
' + + '
' + + '
' + + '
' +} + +function generateNestedCommentString(data) { + var output = '' + data.comments.forEach(function(comment) { + output+= renderNestedComment(comment) + if (comment.comments) { + output+= generateNestedCommentString(comment) + } + }) + return output +} + +/** + * Fetch details of the story/post/item with (nested) comments + * TODO: Add article summary at top of nested comment thread + */ +exports.fetchItem = function(itemId) { + return fetch('https://node-hnapi.herokuapp.com/item/' + itemId).then(function(response) { + return response.json() + }).then(function(json) { + var comments = '' + json.comments.forEach(function(data, index) { + var comment = '
' + + '
' + + '
' + + '
[–] ' + + '' + data.user + ' ' + + ' ' + + 'link
' + + '
' + + '
' + data.content +'
' + + '

reply

' + + '
' + + '
' + + '
' + comments += generateNestedCommentString(data) + '
' + comment + }) + return comments + }) +} \ No newline at end of file diff --git a/package.json b/package.json index 3279d35..e918b28 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,14 @@ "main": "server.js", "dependencies": { "ejs": "^2.4.1", + "eslint-config-jonnybuchanan": "2.0.3", "events": "1.1.0", "express": "^4.13.4", "firebase": "2.4.2", "history": "2.1.1", "isomorphic-fetch": "^2.2.1", + "nwb": "0.8.1", + "object-assign": "^4.1.0", "react": "15.0.2", "react-dom": "15.0.2", "react-router": "2.4.0", @@ -36,10 +39,8 @@ "reactfire": "0.7.0", "scroll-behavior": "0.5.0", "setimmediate": "1.0.4", - "url-parse": "^1.1.1", - "eslint-config-jonnybuchanan": "2.0.3", - "nwb": "0.8.1", "sw-precache": "^3.1.1", - "sw-toolbox": "^3.1.1" + "sw-toolbox": "^3.1.1", + "url-parse": "^1.1.1" } } diff --git a/server.js b/server.js index dda4625..d163804 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,8 @@ var express = require('express') var React = require('react') var renderToString = require('react-dom/server').renderToString var ReactRouter = require('react-router') +var objectAssign = require('object-assign') +var HNServerFetch = require('./hn-server-fetch') require('babel/register') var routes = require('./src/routes') @@ -12,6 +14,47 @@ app.set('views', process.cwd() + '/src/views') app.set('port', (process.env.PORT || 5000)) app.use(express.static('public')) + +app.get(['/', '/news'], function(req, res) { + ReactRouter.match({ + routes: routes, + location: req.url + }, function(err, redirectLocation, props) { + if (err) { + res.status(500).send(err.message) + } + else if (redirectLocation) { + res.redirect(302, redirectLocation.pathname + redirectLocation.search) + } + else if (props) { + HNServerFetch.fetchNews().then(function(stories) { + objectAssign(props.params, { prebootHTML: stories }) + var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) + res.render('index', { markup: markup }) + }) + } + else { + res.sendStatus(404) + } + }) +}) + +app.get('/news/story/:id', function (req, res, next) { + var storyId = req.params.id + ReactRouter.match({ + routes: routes, + location: req.url + }, function(err, redirectLocation, props) { + if (storyId) { + HNServerFetch.fetchItem(storyId).then(function(comments) { + objectAssign(props.params, { prebootHTML: comments }) + var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) + res.render('index', { markup: markup }) + }) + } + }) +}); + app.get('*', function(req, res) { ReactRouter.match({ routes: routes, @@ -24,10 +67,8 @@ app.get('*', function(req, res) { res.redirect(302, redirectLocation.pathname + redirectLocation.search) } else if (props) { - var markup = renderToString( - React.createElement(ReactRouter.RouterContext, props, null) - ) - res.render('index', { markup: markup }) + var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) + res.render('index', { markup: markup }) } else { res.sendStatus(404) diff --git a/src/App.js b/src/App.js index 15aaa7a..ea9e480 100644 --- a/src/App.js +++ b/src/App.js @@ -10,7 +10,9 @@ var SettingsStore = require('./stores/SettingsStore') var App = React.createClass({ getInitialState() { return { - showSettings: false + showSettings: false, + showChildren: false, + prebootHTML: this.props.params.prebootHTML } }, @@ -22,6 +24,11 @@ var App = React.createClass({ window.addEventListener('beforeunload', this.handleBeforeUnload) }, + componentDidMount() { + // Empty the prebooted HTML and hydrate using live results from Firebase + this.setState({ prebootHTML: '', showChildren: true }) + }, + componentWillUnmount() { if (typeof window === 'undefined') return window.removeEventListener('beforeunload', this.handleBeforeUnload) @@ -58,7 +65,8 @@ var App = React.createClass({ {this.state.showSettings && }
- {this.props.children} +
+ {this.state.showChildren ? this.props.children : ''}
insin/react-hn diff --git a/src/Stories.js b/src/Stories.js index c60d523..dcc5368 100644 --- a/src/Stories.js +++ b/src/Stories.js @@ -67,7 +67,7 @@ var Stories = React.createClass({ // Display a list of placeholder items while we're waiting for the initial // list of story ids to load from Firebase. - if (this.state.stories.length === 0 && this.state.ids.length === 0) { + if (this.state.stories.length === 0 && this.state.ids.length === 0 && this.getPageNumber() > 0) { var dummyItems = [] for (var i = page.startIndex; i < page.endIndex; i++) { dummyItems.push( diff --git a/src/views/index.ejs b/src/views/index.ejs index 202afd3..0bfda24 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -29,7 +29,7 @@ - +