preface

Maybe if we were working in a company other than infrastructure, we would not be asked to do react SSR. The company framework is already in place. However, as someone who works toward advanced development, React SSR is a very important thing to learn and practice.

Some of the code will be posted, but not all (it will be too long), and the complete code for each part can be viewed on Github.

Then, the technique used in this article is the React Node Webpack

Setting up the development environment

Complete a simple SSR

Initialize the project first

$ npm init -y
Copy the code

Install some dependencies

$ npm i react react-dom express
$ npm i @babel/{cli,core,preset-env,preset-react} babel-loader webpack webpack-cli webpack-node-externals -D
Copy the code

Current directory structure:

├ ─ ─ dist directory / / packaging production │ ├ ─ ─ client / / front-end │ | ├ ─ ─ index. The js / / documents after packaging │ ├ ─ ─ server / / server │ | ├ ─ ─ index. The js / / start node entry ├ ─ ─ SRC / / the source file │ ├ ─ ─ client / / front end folder │ | ├ ─ ─ pages / / page file | | ├ ─ ─ index. The js / / front entrance │ ├ ─ ─ server / / server folder | | ├ ─ ─ index. The js / / Server entryCopy the code

The client simply writes a component

const Index = () = > {
  return (
    <div>
      i am fruit
    </div>)}; ReactDOM.hydrate(<Fruit />.document.getElementById('root'));Copy the code

React16 provides hydrate and the difference between Render and Hydrate is:

  • renderClient rendering results are followed, that is, if the client and server render results are inconsistent, the server render results are overwritten and the client render results are used.
  • hydrateWhen rendering on the server side, the server rendering results are preserved to the maximum extent possible.

The server uses renderToString to render components into raw HTML

import { renderToString } from 'react-dom/server';

const app = express();

app.get(The '*'.(req, res) = > {
  const reactStr = renderToString(<Fruit />);

  const html = ` <! DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> </head> <body> <div id="root">${reactStr}</div>
  </body>
  </html>`;

  return res.send(html);
});
Copy the code

Since you are initializing the project yourself, you also need to configure the WebPack, two webPacks for the client and one for the server. Client, no attention, normal configuration can be. On the server side, you need to configure target and externals. The former represents the deployment target and the webpack will be compiled for the Node/Web environment. The latter eliminates modules that do not need to be packed and reduces the packing volume.

Externals on the server uses webpack-node-externals to exclude modules that do not need to be packaged.

Because currently, the webpack configuration of the client and server is similar, just post the server configuration, you can go to Github to see the complete code.

const WebpackNodeExternals = require('webpack-node-externals')
const { resolvePath } = require('./util');
// const resolvePath = pathStr => path.join(__dirname, pathStr);

module.exports = {
  mode: 'development'.target: 'node'./ / the node environment
  entry: resolvePath('.. /src/server/index.js'),
  output: {
    filename: 'index.js'.path: resolvePath('.. /dist/server')},externals: [WebpackNodeExternals()],  // Eliminate unneeded packaging modules
  module: {
    rules: [{
      test: /.jsx? $/,
      use: 'babel-loader'.exclude: /node_modules/}}};Copy the code

Finally, the babelrc configuration:

{
  "presets": [
    "@babel/preset-env"."@babel/preset-react"]}Copy the code

Finally, run the NPM command to implement a basic REACT SSR.

{
  "client:dev": "webpack --config ./webpack/webpack.client.dev.js"."server:dev": "webpack --config ./webpack/webpack.server.dev.js"."node:dev": "node ./dist/server/index.js"
}
Copy the code

Current Complete code (SSR-BASIC)

Of course, it is very simple, the key is not listening to the code, each modification, have to repeat the above process, it is uncomfortable.

Improve the development experience

Install some dependencies

$ npm i nodemon webpack-merge clean-webpack-plugin -D
Copy the code

Because there is currently a lot of duplication in the webpack configuration of the code, front-end and server, reuse configuration items with Webpack-merge.

module.exports = {  // Remove the same part
  resolve: {
    extensions: ['.js'.'.jsx']},module: {
    rules: [{
      test: /.jsx? $/,
      use: 'babel-loader'.exclude: /node_modules/}},plugins: [
    new CleanWebpackPlugin()
  ]
}
const merge = require('webpack-merge');
const base = require('./webpack.base');

module.exports = merge(base, {
    / /...
});
Copy the code

For front-end and server source code, webpack — Watch is used to monitor code changes. For the running of the code packaged by the server, Nodemon is used to run the monitoring modification.

{
  "client:dev": "webpack --watch --config ./webpack/webpack.client.dev.js"."server:dev": "webpack --watch --config ./webpack/webpack.server.dev.js"."node:dev": "nodemon ./dist/server/index.js -w ./dist"."dev": "npm run client:dev & npm run server:dev & npm run node:dev"
}
Copy the code

However, there is still a defect, when there is no dist in the directory, that is, when NPM run dev is run for the first time, Nodemon will not find the./dist/server/index.js file, so it will become Nodemon __dirname/index.js, and an error will occur.

Solution: Write a Node script and check if the file already exists. If not, create a new folder & file.

const judgeFolder = (pathStr) = > {  // Check if there is a folder
  if(! fs.existsSync(resolvePath(pathStr))) { fs.mkdirSync(resolvePath(pathStr)) }; }const buildFile = () = > {   
  const aimPath = resolvePath('.. /.. /dist/server/index.js');
  if(! fs.existsSync(aimPath)) {// Check whether files exist

    judgeFolder('.. /.. /dist');
    judgeFolder('.. /.. /dist/server');

    fs.writeFileSync(aimPath, "console.log('build done')"); }}; buildFile();Copy the code

To prevent an exception between the Node script and our NPM run Server :dev, I asked them to run the Node script first and then run the three listening file operations in parallel.

{
  "pre:file": "node ./webpack/scripts/pre-file.js"."dev": "npm run pre:file && npm run client:dev & npm run server:dev & npm run node:dev"
}
Copy the code

The difference between && and & is that && is secondary execution; & Concurrent execution.

At present, it is automatically compiled, but the browser side needs to manually refresh, and you can use webpack-dev-server and react-hot-loader to implement hot update. Because this article is mainly about the react SSR practice process, we will not elaborate on this method.

Complete code (SSR-DEV)

homogeneous

Isomorphism is a set of code that can run both on the server side and on the client side, which is a combination of server-side rendering and client-side rendering, where the browser takes over the page after the server component is rendered.

So based on the above, now the server directly out of the component, need to let the browser take over, need to package the client js file output.

Modify directory structure:

├ ─ ─ SRC / / the source file │ ├ ─ ─ constant / / place some constants | | ├ ─ ─ index. The js / / constants file │ ├ ─ ─ server / / server folder | | ├ ─ ─ middleware / / middleware folder | | ├ ─ ─ util/method/function file | | ├ ─ ─ index. The js / / the service side entranceCopy the code

Write server direct out components to middleware via express.static hosted static files

const app = express();

app.use(express.static('./dist/client'));   // Host static files
app.use(ssr);
export default (req, res, next) => {    // SSR middleware
  const { path, url } = req;

  if (url.indexOf('. ') > -1) {  // Add a simple handle
    return;
  };

  const reactStr = renderToString(<Fruit />);
  const htmlInfo = {
    reactStr,
  };
  const html = handleHtml(htmlInfo);    // Is the previous HTML splicing
  res.send(html);

  return next();
};
Copy the code

Because express.static(‘./dist/client’) hosts static files and index.js is in./dist/client/index.js, HTML splicing needs to add:

<script type="text/javascript" src="/index.js"></script>
Copy the code

Routing isomorphism

Route isomorphism means that the front and back ends adopt the same routine, and the front end still writes routes as before in SPA. On the server side, the component is found by the path of the current request and output.

Install some dependencies

$ npm i react-router react-router-dom
Copy the code

Define a routing configuration file route.config.js.

[{
  component: Fruit,
  path: '/'.exact: true.name: 'Fruits'}, {... }]Copy the code

The front entrance, just like we wrote spa before, is not much to say.

const App = () = > {
  return (
    <Fragment>
      <Header />// The one I defined,<link>Jump component<Switch>{ routeConfig.map(v => { const { name, ... rest } = v; return<Route key={v.path} {. v} / >})}</Switch>
    </Fragment>)}; ReactDOM.hydrate(<BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById('root')
);
Copy the code

The server performs route lookup through the StaticRouter provided by The React-Router4. This component accepts two parameters: Location, the path of the incoming request, is used to find the routing context, and can pass in some initial data. Also, if the internal route has a Redirect and so on, it can add a URL field to its object. The server can use this to determine 404, 302/301 and so on.

export default (req, res, next) => {
  const { path, url } = req;

  if (url.indexOf('. ') > -1) {
    return;
  };

  const reactStr = renderToString(
    <StaticRouter location={path}>
      <App />
    </StaticRouter>
  );

  const htmlInfo = {
    reactStr,
  };

  const html = handleHtml(htmlInfo);
  res.send(html);

  return next();
};
Copy the code

So at this point, isomorphism is complete.

Complete code (SSR-Router)

Data isomorphism

Data isomorphism is very important, that is, the same set of code to request data, with the same data to render. So there are three questions:

  • When a server exports a component, it needs to complete the request and carry the data.
  • When the browser takes over the page, it needs to have this data, so it doesn’t rerequest it or have no data at all;
  • If the browser takes over and switches to this route from another route, the browser initiates a request.

Install dependencies

$ npm i react-router-config
$ npm i @babel/plugin-transform-runtime -D
Copy the code

Add plugins to the.babelrc configuration and compile async await

{..."plugins": [
    "@babel/plugin-transform-runtime"]}Copy the code

Customize a data file to simulate a request:

export const fruitData = {  // src/client/pages/data/index.js
  name: 'fruitData'
};
Copy the code

Simulate request data for fruit.js

Index.preFetch = async() = > {const fetchData = () = > {
    return new Promise(res= > {
      setTimeout(() = > {
        res({
          data: fruitData
        })
      }, 300);  // 300ms simulated request data latency
    });
  };

  const data = await fetchData();
  return data;
};
Copy the code

React-router-config provides matchRoutes for finding components by passing in an array of path and route configurations. After obtaining the component, determine whether the component has a static method preFetch, if so, request to obtain data. Pass the data through the context of StaticRouter. Then, the server gets the data, and the server outbound component carries the data.

The server also sends data to the client while exporting components, a process called water injection.

Pass the data through textarea, of course, if you do not want to clear text, can be encrypted.

export default async (req, res, next) => {
  const { path, url } = req;
  if (url.indexOf('. ') > -1) {  // Let's make it simple
    return;
  };

  const branch = matchRoutes(routeConfig, path)[0];
  let component = {};
  if (branch) { 
    component = branch.route.component;
  };

  let initialData = {}
  if (component.preFetch) { // Check whether the component is requested
    initialData = await component.preFetch();
  };

  const context = {
    initialData
  };
  const reactStr = renderToString( 
    <StaticRouter location={path} context={context}>// Context passes data<App />
    </StaticRouter>
  );
  const htmlInfo = {
    reactStr,
    initialData: JSON.stringify(initialData)
  };
  const html = handleHtml(htmlInfo);

  res.send(html);

  return next();
};
Copy the code

For HTML splicing, also add this paragraph, water

<textarea id="textareaSsrData" style="display: none">${initialData}</textarea>
Copy the code

So for the client, it needs to dehydrate, and then pass the dehydrated data to the corresponding component. This can be passed through the Render property of Route.

const pathname = document.location.pathname;
const initialData = JSON.parse(document.getElementById('textareaSsrData').value);
// Get the current path and data and pass it to the App component
ReactDOM.hydrate(
  <BrowserRouter>
    <App pathname={pathname} initialData={initialData} />
  </BrowserRouter>.document.getElementById('root'));const App = ({ pathname, initialData }) = > {
  return (
    <Fragment>
      <Header />
      <Switch>{ routeConfig.map(v => { const { name, ... rest } = v; If (pathName === v.path) {const {component: component,... _rest } = rest; return<Route key={v.path} {. _rest} render={(props)= >{ props.initialData = initialData; // Pass data return<Component {. props} / >
              }} />
            } else {
              return <Route key={v.path} {. rest} / >}})}</Switch>
    </Fragment>)};Copy the code

At this point, either the data requested by the server or the dehydrated data has been passed to the component. The component does not know whether it is on the server side or the client side, so it needs a field to indicate whether it is on the server side or the client side, and then goes to the appropriate place to fetch data.

You can define a global constant via webpack.definePlugin to indicate what environment you are in.

plugins: [  / / configuration webpack
    new webpack.DefinePlugin({
      '__isServer': true.// Set true for the server and false for the client}),]Copy the code

Create a new folder util under SRC /client and store the general method: determine what the current environment is and therefore where to get data.

export const envInitialData = (props) = > {  // The parameter is passed to props
  let initialData;

  if (__isServer) { // The StaticRouter context is passed to props. StaticContext
    initialData = props.staticContext.initialData;
  } else {  // render (props) =>... Introduced to props. XXX
    initialData = props.initialData;
  };

  return initialData || {};
};
Copy the code

The corresponding component then calls this method to get the initial value so that both ends render the same.

const Index = (props) = > {
  const[info, setInfo] = useState(envInitialData(props).data || {}); .return (
    <div onClick={click}>
      page: Fruit
      <span>I am {info.name}</span>
    </div>)};Copy the code

So so far, the first two of the first three problems have been solved. That leaves a third problem: jumping to this route from another route requires a browser request. This can add a judgment, if there is no data, then initiate a request.

useEffect(() = > {
  const getData = async() = > {const { data } = await Index.preFetch();
    setInfo(data);
  };
  Void 0 = undefined; void 0 = null; void 0 = undefined
  if (info.name === void 0) { getData(); }} []);Copy the code

So at this point, isomorphism is complete.

301/302 404, etc

As mentioned earlier, if the StaticRouter has a route jump, it can handle 301/302/404 and so on, and server handling is relatively simple. Check whether context.url exists, and then check the URL.

if (context.url) {  // If there are any, jump to 302
  res.writeHead(302, {
    location: context.url
  });
  res.end();
} else {
  const html = handleHtml(htmlInfo);
  res.send(html);
}
Copy the code

Complete code (SSR-data)

Other chapters:

React SSR Practice (part 2)

React SSR Practice (part 3)