Implementando SSR com React e Express
Caio Fuzatto
Posted on September 28, 2023
Fala galera, hoje estou com a ideia de criar meu próprio servidor de React utilizando ExpressJS. A ideia é criar a lógica de SSR com as apis do React e utilizar o Express para cuidar das rotas e do servidor. Vamos nessa?
Se você quiser entender os conceitos que estamos utilizando aqui, recomendo que leia o meu post sobre o Universo de renderização do React.
Criando o projeto
Bom galera criei uma pasta chamada react-ssr
e comecei rodando yarn init
para criar o projeto. Depois disso instalei as dependências do express e do react com yarn add express react react-dom
.
Typescript
Para utilizar o typescript no projeto, instalei as dependências com:
yarn add -D typescript @types/react @types/react-dom @types/express
Depois disso criei o arquivo tsconfig.json
com o comando npx tsc --init
. As minhas configurações do typescript ficaram assim:
{
"compilerOptions": {
"target": "ESNext",
"jsx": "react",
"module": "CommonJS",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
Testando a instalação
Agora para testar a instalação vamos fazer um hello world. Crie o arquivo index.html
com o seguinte código:
<!DOCTYPE html>
<html lang="pt-br">
<head>
<title>Meu React App</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
Observe que nosso HTML é bem semelhante ao que vem na pasta
public
de uma instalação feita comcreate-react-app
.
Agora vamos criar nosso primeiro componente React. Crie o arquivo App.tsx
e cole o código abaixo:
import React from 'react'
export const App = () => {
return (
<div>
<h1>Hello world</h1>
</div>
);
}
Por fim vamos criar o arquivo index.ts
que será o arquivo principal do nosso servidor React com Express, cole o seguinte código:
import path from "path";
import fs from "fs";
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { App } from "./App";
const app = express();
app.get("/", (req, res) => {
const appContent = ReactDOMServer.renderToString(React.createElement(App));
const indexFile = path.resolve("./index.html");
fs.readFile(indexFile, "utf8", (err, data) => {
if (err) {
return res.status(500).send("Não foi possível carregar o app.");
}
return res.send(
data.replace('<div id="root"></div>', `<div id="root">${appContent}</div>`)
);
});
});
app.listen(8080, () => {
console.log(`App rodando na porta ${8080}`);
});
Para testar vamos rodar npx ts-node index.ts
e acessar o localhost:8080
. Se tudo deu certo, você deve ver o hello world na tela:
Até esse momento criamos uma implementação básica simulando um SSR. Basicamente a gente está pegando um HTML base que é nosso index.html e substituindo o conteúdo da div root pelo conteúdo do nosso componente App que foi gerado utilizando o
ReactDOMServer.renderToString
.
Hidratação do HTML
Nesse ponto já temos uma implementação básica de SSR, mas ainda não temos a hidratação, ou seja, até aqui o React está 100% no servidor, se a gente adicionar um evento no botão, utilizando um useState
por exemplo, ele não vai funcionar. Então vamos fazer a hidratação do nosso HTML. Vamos começar criando um componente React que utiliza um hook client-side para nosso teste. Altere o arquivo App.tsx
para o seguinte:
import React from 'react'
export const App = () => {
const [state, setState] = React.useState(0);
const count = () => {
const newState = state + 1
setState(newState);
console.log(newState)
}
return (
<div>
<h1>Counter: {state}</h1>
<button onClick={count}>Counter</button>
</div>
);
}
Instalando e configurando o webpack
O Webpack é uma ferramenta que nos permite fazer o bundling do nosso código, ou seja, ele vai pegar todos os nossos arquivos e juntar em um só arquivo. Isso é muito útil para o browser, pois ele só precisa fazer uma requisição para o servidor e não várias. Nesse caso vamos utilizar o webpack para gerar um arquivo bundle.js
que vai conter todo o nosso código React em Javascript.
Antes de tudo vamos a uma pequena reorganização dos nossos arquivos:
- Vamos mover o arquivo
App.tsx
para a pastasrc/client/App.tsx
- O arquivo
index.ts
para a pastasrc/server/index.ts
.
Para instalar as dependências do webpack rode o comando:
yarn add --dev ts-loader webpack webpack-cli
Agora vamos criar o arquivo webpack.config.js
com o seguinte conteúdo:
const path = require("path");
const clientConfig = {
mode: "development",
target: "web",
entry: "./src/client/index.tsx",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist/public"),
},
};
const serverConfig = {
mode: "development",
entry: "./src/server/index.ts",
target: "node",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
output: {
filename: "index.js",
path: path.resolve(__dirname, "dist"),
},
};
module.exports = [clientConfig, serverConfig];
Estamos configurando o webpack para gerar dois arquivos, um para o client e outro para o server. O arquivo do client vai ser gerado na pasta
dist/public
e o do server na pastadist
.
Melhorando o ambiente de desenvolvimento
Para facilitar nossa vida vamos instalar duas libs que são ótimas no ambiente de desenvolvimento. A primeira é a nodemon
que vai ficar observando nossos arquivos e reiniciando o servidor sempre que houver alguma alteração. A segunda é a concurrently
que vai nos permitir rodar o webpack e o nodemon ao mesmo tempo utilizando um só comando. Para instalar as dependências rode o comando:
yarn add --dev nodemon concurrently
Com as libs instaladas, bora criar nosso primeiro script. No seu package.json
, adicione:
"scripts": {
"dev": "webpack && concurrently \"webpack --watch\" \"nodemon dist\""
},
E seu tudo deu certo, agora é só rodar yarn dev
e temos um counter funcionando:
Streaming SSR
Vamos dar um passo além e fazer o streaming do SSR, funcionalidade que é o coração do React Server Components. Primeiro vamos alterar nossa rota do express para funcionar como um socket e nossa resposta vai ser um stream. Para isso vamos utilizar o método ReactDOMServer.renderToPipeableStream
. Altere o arquivo src/server/index.ts
para o seguinte:
// ...
app.get('/', (req, res) => {
res.socket.on('error', (error) => console.log('Fatal', error));
let didError = false;
const stream = ReactDOMServer.renderToPipeableStream(
React.createElement(App),
{
bootstrapScripts: ['/bundle.js'],
onShellReady: () => {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
onError: (error) => {
didError = true;
console.log(error);
}
}
);
});
// ...
E também vamos criar nosso componente que será renderizado no lado do servidor. Crie um novo arquivo src/client/HeavyComponent.tsx
com o seguinte conteúdo:
import React from "react";
let status = "pending";
let result = null;
const asyncFunc = (func: Promise<any>) => {
const fetching = func
.then((success) => {
status = "fulfilled";
result = success;
})
.catch((error) => {
status = "rejected";
result = error;
});
if (status === "pending") {
throw fetching;
} else if (status === "rejected") {
throw result;
} else if (status === "fulfilled") {
return result;
}
};
export default function HeavyComponent() {
const data = asyncFunc(getData());
async function getData() {
await new Promise((resolve) => setTimeout(resolve, 5000));
return { username: "itsmicaio" };
}
return (
<div>
<h3>Demorei mas carreguei!</h3>
<p>{JSON.stringify(data)}</p>
</div>
);
}
Declaramos a variável status="pending"
que será utilizada para informar ao React o estado da promessa. Enquanto estiver pendente o React vai renderizar o fallback da Suspense API
.
A função asyncFunc
é uma função que simula uma chamada assíncrona. Ela recebe uma promise e retorna o resultado dela. Se a promise ainda não foi resolvida, ela lança um erro que é capturado pelo React e renderiza o fallback. Se a promise já foi resolvida, ela retorna o resultado.
Finalmente nossa função que busca os dados. Ela é uma função assíncrona que espera 5 segundos e retorna um objeto com o meu username.
Pra gente conseguir ver a magica acontecendo, vamos utilizar a Suspense API
junto com o HeavyComponent
. Altere o arquivo App.tsx
para o seguinte:
import React, { lazy, Suspense } from "react";
const HeavyComponent = lazy(() => import("./HeavyComponent"));
export const App = () => {
return (
<div id="root">
<h1>Test suspense</h1>
<Suspense fallback={<h3>Carregando...</h3>}>
<HeavyComponent />
</Suspense>
<Counter />
</div>
);
};
E é só dar um reload na página e vamos conseguir ver nosso componente sendo carregado e posteriormente renderizado na página:
Explicando o HTML final
Se você olhar o HTML final que foi gerado, vai ver que ele está bem diferente do que a gente tinha antes. Agora temos um comentário <!--$?-->
que indica o inicio do nosso componente e um comentário <!--/$-->
que indica o fim do nosso componente. E no meio desses comentários temos um template com um id que é onde o nosso componente deve ser inserido após o carregamento.
<div>
<h1>Test suspense</h1>
<!--$?-->
<template id="B:0"></template>
<h3>Carregando...</h3>
<!--/$-->
<div id="counter">
<h1>Counter:
<!-- -->
0</h1>
<button>Counter</button>
</div>
</div>
<script src="/bundle.js" async=""></script>
<div hidden id="S:0">
<div>
<h3>Demorei mas carreguei!</h3>
<p>{"username ":"itsmicaio "}</p>
</div>
</div>
<script>
function $RC(a, b) {
a = document.getElementById(a);
b = document.getElementById(b);
b.parentNode.removeChild(b);
if (a) {
a = a.previousSibling;
var f = a.parentNode
, c = a.nextSibling
, e = 0;
do {
if (c && 8 === c.nodeType) {
var d = c.data;
if ("/$" === d)
if (0 === e)
break;
else
e--;
else
"$" !== d && "$?" !== d && "$!" !== d || e++
}
d = c.nextSibling;
f.removeChild(c);
c = d
} while (c);
for (; b.firstChild; )
f.insertBefore(b.firstChild, c);
a.data = "$";
a._reactRetry && a._reactRetry()
}
}
;$RC("B:0", "S:0")
</script>
No final do HTML também temos um script que é responsável por fazer a inserção do nosso componente no HTML. Ele basicamente pega o template com o id B:0
e substitui pelo conteúdo da div com o id S:0
que é justamente o componente carregado no servidor.
Conclusão
Viu só que legal? Deu pra entender de forma mais profunda como funciona as formas de renderização de React e algumas implementações que estão acontecendo por de baixo dos panos em ferramentas como NextJS e Gatsby. É claro que essa é uma implementação bem simples e não recomendo que você utilize em produção, mas é uma ótima forma de entender como as coisas funcionam.
Você pode ver o código completo no github e também pode ver o artigo que fiz explicando o Universo de renderização do React. Fui :p
Posted on September 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 18, 2024
March 7, 2024