The goals of the stack:
- Easy to develop on
- Uses common languages, technologies and libraries
- Minimal code complexity
- High-performance
- Fast client-side rendering and reaction to changes
- Fast server-side rendering, and cacheability
- Accessible
- Works with current and old browsers
- Server-side rendering for SEO, screen readers, and old devices
We chose a ES6+ / node / react.js stack because:
- Javascript, because browsers use Javascript - so we must choose either the language itself or a language that compiles to Javascript. To reduce barriers to entry, and to write code which is closer to the output, Javascript or a similar variant (Typescript, or future versions such as ES6+) are goods choices. Some languages and frameworks get around this by abstracting out direct manipulation of the UX layer (Rails / Turbolinks is one notable example), but that does not allow building a rich UI without also duplicating a large amount of code in different languages between server- and client- sides if one wants to provide server-side rendering. (In our case, we do.) Additionally, a large majority of existing Javascript libraries have chosen to use JS because it is "universal" within the community, so rather than choosing a language such as Coffeescript, Javascript itself is more compatible with an open-source codebase.
- ECMAScript version 6+ provides many utilities to write cleaner-looking code, without abstracting into another language. It is forward-facing, in that we are using upcoming official versions of Javascript, with future compatibility.
- Node.js allows you to run the same code on the server and the client, allowing us to write easily testable libraries and minimize code duplication. Rather than re-implementing logic in two languages, we can use Javascript and write it once.
- React.js is a templating system (not a full client-side stack) which allows building DOM output and updating it based on data changes. It uses logic to watch deltas in data passed into elements, which makes it easy to write performant DOM-update code. It has also rapidly risen in popularity, and internally, reddit was experimenting with React.js on most new products, so we went with react.js.
tl;dr:
request ->
server ->
router -> { build properties for view }
view -> { put properties in an html template }
{ send html to client }
browser ->
load html, css, and javascript
initialize javascript, bind links to client-side router
click link ->
view -> { put properties in an html template }
{ render DOM updates }
- A request goes to the server, which runs middleware
- One of the middlewares is a mounted app, which responds to urls on the request context
- Properties to send to the views are added to the request context
(
context.props
) - A page is rendered using
context.props
, waiting for API calls to finish if any are listed incontext.props.data
- The browser gets html, then mounts a copy of the same app code that was mounted on the server.
- Link clicks go through the app, and render client-side, rendering
immediately, and progressively updating as ajax calls finish (from
context.props.data
, set in the route)
More detail:
- A request is made to https://m.reddit.com
- The request first goes through a CDN, with some logic, such as:
- Do not cache if there is a "token" cookie;
- Do not cache if there are cookies that manipulate view output (such as "compact mode")
- If the request is not immediately returned by the CDN, the request continues on to AWS. It moves through a load balancer onto a web server.
- The web server has several clustered node processes (one per core, by
default configuration), managed by node's built-in clustering API. The
request is then handled by a
Server
instance. - The
Server
is a class with an instance ofkoa
, a generator-based node webserver. The request passes through asynchronous middleware, such as a function that puts some cookie values into the request context and one which sets a cookie for logged-out anonymous tracking ids.Server
also has an instance of ahorse-react
app
. The request is handed byapp.render
at the end of the middleware call stack (see code) app.render
uses arouter
to call a function based on request url. The router then uses its own middleware; one such sets default properties oncontext.props
such as (title
,user
, andcountry
), which are later sent to the view for rendering. After the router middleware stack is finished, it matches the route (/
, which callsindexPage
, in our example.) (see code)- The
indexPage
function then:- Sets values on
context.props
, which will be passed on to the views, such as subreddit name, current page, and current sort. (see code) - Builds an API query for links, including data such as subreddit name and sort. (see code)
- Adds an api call to
context.props.data
through callingsetData
. API calls return promises. In this case,setData
is called rather than the direct API call toapi.listings.get
because we want this data to load from the cache - and setData will hydrate the API cache if available (currently only turned on client-side.) Otherwise, one could simply usethis.props.data.set('listings', this.props.api.listings.get(options));
. (see code) - Finally,
makeBody
is called withIndexPage
, which is the React page element we want to render.react-horse
expectscontext.body
to be a function which acceptsprops
and returns a React element;makeBody
abstracts some of the logic so that you can call it with one or more elements. (see code)
- Sets values on
- Once the route is complete, the server then waits for the response from all
promises in
context.props.data
. That data is loaded intocontext.props.dataCache
, and the page is rendered as an html string, which finishes the request cycle, and the response is sent to the browser. (horse-react manages data and rendering; see code) - The value of
context.props
is sent to the client-side in abootstrap
object, which allows the javascript to mount client-side with all of the data returned by the api, as well as shared configuration and context information (such as user token.) - The browser renders the html, CSS, and JS. The same route logic and react
templates used on the server are sent to the browser in a javascript package.
When the browser is ready, it runs code from
client
, instantiates an app, and just like the server, pushes the current URL through the app's router, and renders and mounts the react view. (This is a fast operation as no DOM changes should occur - but events, such asonClick
, will now be bound on the client.) All links on the page will attempt to run through the router, other than those withtarget=_blank
,data-no-route=true
, or absolute urls with protocols - so from this stage forward, the browser never contacts the node server other than in special cases where it must post data that it cannot to the API (such as login and register.)
A page, and its components, are Javascript classes which return React.js
markup in a render
method. They inherit from BasePage
or BaseComponent
classes, which provide common methods and configuration.
Read up in the react.js docs to learn more about component lifecycle, but it will be discussed at some level of detail here as well.
A page has, at minimum, a render method, which returns JSX markup. A simple page may look like:
import React from 'react';
import BasePage from './BasePage';
class MyPage extends BasePage {
render() {
let { listings } = this.state.data;
if (!listings) { return <h1>Loading</h1>; }
return (
<ul>
{listings.map((l) => {
<li>
<a href={l.permalink}>
{l.title}
</a>
</li>
})}
</ul>
);
}
}
In our pages, when a page gets rendered, it also inherits some things from BasePage, and in turn, BaseComponent. BasePage:
- Reads
props.dataCache
, and sets thestate.data
for the page to the cache if it exists. It will exist in the case that the page is rendered server-side (beacuse the server completed all data promises before rendering) or if the page is being bootstrapped by the first client-side load (because the JSON was passed down to the page.) In cases where a user clicked a link and was routed client-side, dataCache will be empty, and the list ofprops.data
promises will be used instead. The page will attempt to resolve all of theprops.data
promises, and update the page's state when they return, allowing for a progressive data load. - Automatically fires a tracking pixel if a
track
getter is defined on the class. The BasePage will wait for the API call corresponding to the value oftrack
, and then fire its pixel with contextual data (such as logged-out user id and experiments).
If your page uses data, it will be loaded into this.state.data
, which maps
directly to the values in the context.props.data
in the route. For example,
context.props.data.set('comments', this.api.comments.get(options))
would
result in this.state.data.comments
to be set. this.state.meta.comments
also
contains any metainformation (such as tracking pixel url) sent back by the API.
In a page, this.props
maps directly to the props
returned by a route.
this.state
is initialized by BasePage
, which will initially contain only
data
and meta
.
App includes an event emitter. app.emit('name', 'value')
and
app.on('name', function...)
are good ways to send events between decoupled
views. A file, constants.js
, contains commonly used values for event names
and other constants.
Cookies are accessible server-side on the request context (context.cookies.set
and context.cookies.get
, respectively. see
koa documentation for more details.) On the client, it's
preferred to emit an event to change a cookie, handled in client.es6.js via
cookies.get
and cookies.set
. To pass in cookie values in both cases, they
should be read in during the request cycle (koa middleware on the server, or
modifyContext
in the cilent) and passed in as page properties.
While the server does no caching of its own, there a CDN may handle edge caching. This can cause problems if you have cookies or experiments that change the output of the page based on their value. Make sure to either add exceptions for experiments in the CDN settings, or else you may have to have the client display updated output and lose the benefits of server-side rendering.