Is serverside render a big headache for your Single Page App?
say you need head title, description, jsonld, og...
perhaps fetch data from db, then render redux preloadedState
so many things need to be rendered in HTML
What if we let component define
what they need in static method?
What if we could fetch database in component?
Coren
provide you pluggable, flexible way to render your html
- 🔌Pluggable: You can customize your own collector for your own need
- Access to Component Props: in componentDidConstruct method from Lifecycle Hook you can access to component props
- Pass Variables To Component: Collector can pass anything you want(
DB Query
,Server API
) to Define method
$ npm install coren --save
Here's some simple example components using collector
we'll insert what component want as <title> to HTML
@collector()
export default class User extends Component {
// Put `user ${props.userId}` title tag to HTML
static defineHead(props) {
return {
title: `user ${props.userId}`
}
}
render() {
return <div>
...
</div>;
}
}
In MultiRoutesRenderer, HeadCollector insert head using appendToHead
class HeadCollector {
constructor() {
this.heads = [];
}
// ...
componentDidConstruct(id, component, props) {
this.heads.push(component.defineHead(props));
}
getFirstHead() {
return this.heads[0] || {};
}
// ...
appendToHead($head) {
const {title, description} = this.getFirstHead();
$head.append(`<title>${title}</title>`);
$head.append(`<meta name="description" content="${description}">`);
}
}
const app = new App({
path: path.resolve(__dirname, 'path/to/app')
});
// HeadCollector get data from `defineHead()`
app.registerCollector("head", new HeadCollector());
// ssr
const ssr = new MultiRoutesRenderer({app});
ssr.renderToString()
.then(result => {
console.log(result);
// [{route: "/", html: "<html><head>user 1</head>...</html>"}]
})
.catch(err => console.log(err));
How Coren
render __PRELOADED_STATE__
@collector()
export default class Product extends Component {
// Fetch data first
// then, during serverside render, put `window.__PRELOADED_STATE__=${state}` to HTML
static definePreloadedState({db}) {
return db.fetch('products').exec()
.then(data => ({products: data}));
}
render() {
return <div>
...
</div>;
}
}
In ReduxCollector, we push promise we got from definePreloadedState
then, we wait all promises done at appWillRender
Last, we wrap your app with react-redux provider, and get state from store.getState()
, append the state to head
class ReduxCollector {
// ...
componentDidImport(id, component) {
const promise = component.definePreloadedState(this.componentProps);
this.queries.push(promise);
}
appWillRender() {
return Promise.map(this.queries,
state => Object.assign(this.initialState, state));
}
wrapElement(appElement) {
const store = createStore(this.reducers, this.initialState);
const wrapedElements = react.createElement(Provider, {store}, appElement);
this.state = store.getState();
return wrapedElements;
}
appendToHead($head) {
$head.append(`<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(this.state)}
</script>`);
}
}
const app = new App({
path: path.resolve(__dirname, 'path/to/app')
});
// ReduxCollector get initialState from `definePreloadedState()`
app.registerCollector("redux", new ReduxCollector({
// componentProps will be passed to
componentProps: {
db
},
// reducer of your app
reducers: reducer
}));
// ssr
const ssr = new MultiRoutesRenderer({app});
ssr.renderToString()
.then(result => {
console.log(result);
// [{route: "/", html: "<html><body>window.__PRELOADED_STATE__={...}</body>></html>"}]
})
.catch(err => console.log(err));
Coren
render html base on data gotten from Component.
so, where do Component write what they could provide for Serverside render?
Component should use @collector
decorator outside, and use static method, prefixed with define
. In this case, @collector
could return data back to server during right lifecycle.
We metioned lifecycle above. How does this work?
let us take a look at collector
decorator
export default function() {
return WrappedComponent => {
const uniqId = shortid.generate();
/*
trigger componentDidImport lifecycle here
notify collectors
*/
hook.componentDidImport(uniqId, WrappedComponent);
class Hoc extends React.Component {
constructor(props) {
super(props);
/*
trigger componentDidConstruct lifecycle here
pass props to collectors
*/
hook.componentDidConstruct(uniqId, WrappedComponent, props);
}
render() {
return <WrappedComponent {...this.props} />;
}
}
return hoistStatic(Hoc, WrappedComponent);
};
}
During serverside render, two lifecycle will be triggered
componentDidImport(id, component)
: called when component importedcomponentDidConstruct(id, component, props)
: called when component constructed
In React-router
, only component matched with route will be rendered. So, component rendered will trigger both methods, on the other hand, component not rendered will trigger only componentDidImport
. It will help you put right data in your HTML.
For Example, we should only put the head
tags return from first constructed component. Components that didn't trigger componentDidConstruct
should not be considered.
What is a Collector
?
Collector
collect data from define
methods, collector can choose which lifecycle it want to call define
method.
For example, we take a look at HeadCollector
, HeadCollector
call defineHead(props)
in componentDidConstruct
, it get {title, description}
, then push to heads array.
when serverside renderer
call appendToHead
, HeadCollector
push the first head it got from component to $head
class HeadCollector {
constructor() {
this.heads = [];
}
// ...
componentDidConstruct(id, component, props) {
this.heads.push(component.defineHead(props));
}
getFirstHead() {
return this.heads[0] || {};
}
// ...
appendToHead($head) {
const {title, description} = this.getFirstHead();
$head.append(`<title>${title}</title>`);
$head.append(`<meta name="description" content="${description}">`);
}
}
App represent your react application. developer use App
to register collector
// create App with path to your React App entry file
const app = new App({
path: path.resolve(__dirname, 'path/to/app')
});
// register collector
app.registerCollector("head", new HeadCollector());
App
controlls lifecycle of all registered collectors.
Serverside renderer will call App
's lifecycle method at certain time, to get the desired result it want.
The main purpose of Serverside Renderer is to create HTML. By calling App
to controll lifecycle of collectors, make sure collectors get the result they want.
In MultiRoutesRenderer
, every collector will go through same phases:
componentDidImport(id, component)
: when component importedappWillRender
: do some async work here if you want to make some api call before renderrouteWillRender
: when rendering multiple routes, appWillRender will be called every time the route match with your component and trigger render, so is every method belowwrapElement
: you can wrap your app reactElement if you need a provider outside- (app renderToString) => ssrRenderer will call ReactDom.renderToString
componentDidConstruct(id, component, props)
: called when component was constructedappendToHead($cheerio('head'))
: append any html to headappendToBody($cheerio('body'))
: append any html to body
- path: path to your React app entry file
const app = new App({
path: path.resolve(__dirname, 'path/to/app')
});
- key: you can directly access to collector by key
app.getCollector("head")
// return headCollector
- collector: the collector you want to register
app.registerCollector("head", new HeadCollector());
app
will use ifEnter
to determine whether call this collector or not
called when component imported, when component imported, a unique id attached to it, so you'll know where this component appeared before or not in componentDidConstruct
.
called when component was constructed
Because we react wont wait for your async code during import
. So a better way to use async related task is to push your promise to an array, wait for them in appWillRender
.
Take reduxCollector for example:
// /src/reduxCollector
componentDidImport(id, component) {
const promise = component.definePreloadedState(this.componentProps);
this.queries.push(promise);
}
appWillRender() {
return Promise.map(this.queries,
state => Object.assign(this.initialState, state));
}
In MultiRoutesRenderer
, you'll have multiple routes to be rendered, so you need a hook to tell your collector when a route is going to be rendered. You can do some reset variable things here.
Take HeadCollector
for example, we make sure we collect fresh head from component constructed.
componentDidConstruct(id, component, props) {
this.heads.push(component.defineHead(props));
}
routeWillRender() {
// empty heads
this.heads = [];
}
Some module require developer wrap ReactElement with provider in serverside render.
Take reduxCollector
for example, we wrap ReactElement with react-redux provider.
wrapElement(appElement) {
const store = createStore(this.reducers, this.initialState);
const wrapedElements = react.createElement(Provider, {store}, appElement);
this.state = store.getState();
return wrapedElements;
}
append any html to head
append any html to body
- npm install coren --save
- use @collector in your component
import collector from 'coren/lib/client/collectorHoc';
@collector()
export default class UserList extends Component {
// ...
render() {
...
}
}
- write
define
method.
@collector()
export default class UserList extends Component {
static defineHead() {
return {
title: "user list",
description: "user list"
};
}
static defineRoutes({Url}) {
return new Url('/users');
}
static definePreloadedState({db}) {
return db.users.find().execAsync()
.then(list => ({
users: {
list,
fetched: true,
isFetching: false,
error: false
}
}));
}
}
- serverside render
serverside render with
app
andmultiRoutesRenderer
const db = mongodb;
const app = new App({
path: path.resolve(__dirname, 'path/to/app')
});
// register collectors
app.registerCollector("head", new HeadCollector());
app.registerCollector("routes", new RoutesCollector({
componentProps: {
db
}
}));
app.registerCollector("redux", new ImmutableReduxCollector({
componentProps: {
db
},
reducers: reducer
}));
// ssr
const ssr = new MultiRoutesRenderer({
app,
// bundle path will be append to html body
js: ["/bundle.js"]
});
// get the array of html result
ssr.renderToString()
.then(results => {
return Promise.all(results.map(result => {
// throw HTML to anywhere you want
// cached to web server, cache server
// write to s3, cdn
}));
})
.catch(err => console.log(err));
Write your own class, implement methods in Collector.
Take a look at built-in collector for reference.
https://github.com/Canner/coren/tree/master/server/collectors
Here's a example repo using this module. https://github.com/Canner/coren-example