Le server-side rendering sans framework: pas si dur!
A l'horizon de 2020, une nouvelle ère s'ouvre devant nous. Il fut un temps où faire du SSR en JavaScript était assez compliqué. On a tous en tête l'article d'Alexis sur Running React Router v4, Redux Saga, SSR and Code Splitting together. Aujourd'hui, notre façon d'écrire a changé, nous en avons fini avec redux-saga, et Redux n'est plus indispensable non plus.
En conservant les mêmes contraintes qu'en 2017, je vais y ajouter des nouveautés. Je veux:
- Mettre à jour la stack technique. React 16, Babel 7, Webpack 4, React Router 5
- Faire du code splitting
- Utiliser React Helmet
- Avoir du hot reload qui fonctionne même en SSR
Pour illustrer mon article, je vais créer un projet d'exemple. Cette application doit lister différents types de fromages et avoir une vue détaillée. Vous pouvez trouver mon code sur github.
Mise en place du projet
J'ai créer une simple API GraphQL et REST sur codesandbox qui me retourne un json qui contient mes différents fromages. Au passage, cela illustre comment on peut faire un serveur sur CodeSandbox, fonctionnalité souvent insoupçonnée.
Vous pouvez retrouver sur la branche 1-minimal-front-back le code du bootstrap du serveur et et celui du client.
Si vous n'êtes pas à l'aise avec Babel et Webpack, je vous envoie vers un tutoriel de Robin Wieruch. Il a également rédigé How to set up an advanced Webpack application, avec l'utilisation de webpack-merge
et webpack-bundle-analyzer
, qui vous sera très utile pour le déploiement et le code splitting.
Au final, la problématique du SSR peut être résumée ainsi: il s'agit de passer d'une page qui contient seulement un élément root
à une page avec tout les éléments HTML.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Listing fromages</title>
<link rel="icon" href="/static/favicon.ico" />
</head>
<body>
<div id="root">
+ Your HTML code
</div>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
+ <script type="text/javascript">window.__APOLLO_STATE__={<!--YOUR APOLLO STATE --></script>
+ <script id="__LOADABLE_REQUIRED_CHUNKS__" type="application/json">[0,2]</script>
+ <script async data-chunk="main" src="/assets/main.js"></script>
+ <script async data-chunk="Fromages" src="/assets/manifest.js"></script>
+ <script async data-chunk="Fromages" src="/assets/Fromages.js"></script>
</body>
</html>
Apollo
J'ai eu pour objectif de simplifier le projet. Non pas seulement mettre à jour les dépendances mais prévenir l'évolution à venir de React. Apollo est un bon client HTTP, ils ont une équipe qualifiée, et qui plus est réactive. Ils ont déjà mis en place les hooks React. Ils suivent de près l'arrivée de <Suspense />
. Ils possèdent aussi une bonne documentation.
Apollo et le server-side rendering
C'est à ce niveau qu'Apollo nous simplifie la vie. Il fournit la fonction getDataFromTree
qui prend l'arbre React, le parcourt et fait les différents appels d'API. Auparavant, nous devions lancer les sagas et mettre le state dans Redux (voir Redux et Saga implementation). Ce n'est plus nécessaire avec Apollo:
const components = (
<ApolloProvider client={client}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</ApolloProvider>
);
await getDataFromTree(components);
const apolloState = client.extract();
// rendering code (see below)
A partir de ce point, toutes les promises sont résolues, et le client Apollo est totalement initialisé. Je peux donc rendre les composants React en HTML.
const html = ReactDOMServer.renderToString(components);
const htmlDocument = getHtmlDocument({
html,
apolloState
});
return res.send(htmlDocument);
J'injecte le HTML ainsi que le state d'Apollo dans mon document et je retourne simplement la réponse.
Apollo client
Maintenant, je vais modifier le côté client pour prendre en compte le state Apollo et hydrater le DOM React.
const client = new ApolloClient({
link: createHttpLink({ uri: "https://ku0n6-4000.sse.codesandbox.io/" }),
cache: new InMemoryCache().restore(window.__APOLLO_STATE__)
});
ReactDOM.hydrate(
<ApolloProvider client={client}>
<Router>
<App />
</Router>
</ApolloProvider>,
document.getElementById("root")
);
A ce stade, le client et le serveur communiquent parfaitement en SSR. On ne peut pas faire plus simple.
Code Splitting
Le sujet suivant est le plus complexe que nous allons aborder. Cette feature permet de découper notre code en plusieurs fichiers qui peuvent être chargés sur demande ou en parallèle. Il y a plusieurs approches pour activer le code splitting.
Je ne vais pas aborder l'import dynamique et React.Lazy, la documentation explique clairement le sujet.
Webpack
Pour Webpack, j'ai mis en place la prévention de duplication de code grâce au plugin SplitChunksPlugin
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\/]node_modules[\/]/,
name: "manifest",
enforce: true
}
}
}
},
Webpack va créer un fichier manifest.js
qui contient le code commun qui se trouve dans les node_modules.
Loadable components
Nous pouvons encore allez plus loin en créant un fichier javascript par page de notre application. Avoir un fichier fromages.js
pour la page listing et un fromage.js
pour la page détail, c'est ce qui nous permet de réaliser ce package.
Vous pouvez retrouver sur la branche 5-loadable-component-chunk, la mise en place de Loadable components.
Après avoir installé les différentes dépendances, je commence par donner des noms au chunk pour chaque page. Webpack impose une syntaxe étrange qui utilise des commentaires:
const Fromage = loadable(() =>
import(/* webpackChunkName: "Fromage" */ "./pages/Fromage")
);
const Fromages = loadable(() =>
import(/* webpackChunkName: "Fromages" */ "./pages/Fromages")
);
Lors du build, webpack va créer un fichier loadable-stats.json
qui contient la description des différents chunk. Il sera lu et utilisé lors du SSR.
const statsFile = path.resolve(
__dirname,
"../../dist/assets/loadable-stats.json"
);
const extractor = new ChunkExtractor({ statsFile });
const context = {};
const components = (
<ApolloProvider client={client}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</ApolloProvider>
);
const jsx = extractor.collectChunks(components);
const scriptTags = extractor.getScriptTags();
A partir de lodable-stats.json
et du nom des chunks, Loadable components peut créer des balises de script pour chaque chunk.
<script async data-chunk="main" src="/assets/main.js"></script>
<script async data-chunk="Fromages" src="/assets/manifest.js"></script>
<script async data-chunk="Fromages" src="/assets/Fromages.js"></script>
Nous retrouvons bien les différents chunck crées.
main.js
pour les dépendances global comme Reacte et Apollo.manifest.js
pour les node_modules communs entre les deux pages.Fromages.js
pour le listing des formages.Fromage.js
pour la page détails.
Helmet
La documentation de Helmet explique très bien la procédure d'utilisation côté serveur. Vous pouvez retrouver mon code sur la branche 4-react-helmet.
Hot reload, où la meilleur éxperience développeur possible
Quoi de mieux que d'être sur un projet ou le hot reload fonctionne ? Webpack va nous faciliter la tâche. Il faut webpack-hot-middleware
et webpack-dev-middleware
.
Je commence par activer le hot reload côté client en modifiant webpack.dev.js
.
module.exports = merge(common, {
mode: "development",
entry: ["webpack-hot-middleware/client"],
plugins: [new webpack.HotModuleReplacementPlugin()]
});
Il ne faut pas oublier d'indiquer que le front autorise le hot reload en changeant client/index.js
if (module.hot) {
module.hot.accept();
}
Pour que cela fonctionne côté serveur, je crée deux middlewares Webpack qui utilisent nos deux plugins en ajoutant l'option serverSideRender
.
const webpackConfig = require("../../webpack.dev.js");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackHotMiddleware = require("webpack-hot-middleware");
const webpack = require("webpack");
const compiler = webpack(webpackConfig);
app.use(
webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath,
serverSideRender: true,
writeToDisk(filePath) {
return /loadable-stats/.test(filePath);
}
})
);
app.use(webpackHotMiddleware(compiler));
J'ajoute l'option writeToDisk
avec un filtre sur le fichier loadable-stats
, ce qui permet au fichier d'être écrit sur le disque. Se rapporter à la partie Loadable components
pour comprendre l'utilité de ce fichier.
Et Suspense ?
Suspense est disponible en version expérimentale, il arrive très prochainement avec la promesse selon laquelle la mise en place du SSR sera plus simple pour les développeurs.
Concurrent Mode et Suspense sont les deux prochaines nouveautés de React qui vont changer notre façon de récupérer des données. Dans un récent article de blog, l'équipe React a repoussé la sortie à plus tard, d'ici la fin d'année, An Update to the Roadmap.
Je suis impatient de tester ces nouveautés.
Aujourd'hui, beaucoup des nouvelles APIs de React sont déjà présentes sur la branche master
mais non documentées (comme useSubscription
et <SuspenseList />
).
Attention à leur utilisation!
Rien de plus simple ?
Comme je l'ai expliqué, créer son propre SSR peut se faire sans trop de difficultés. Cependant cela demande des notions avancées. Il existe plusieurs alternatives qui cachent cette complexité comme Next.js, Gatsby ou encore Razzle.
Je vous renvoie vers un article de google Rendering on the web qui explique les différents types de Server Side Rendering.