preface

With the rise of the front-end, frameworks like Vue and React have become popular, but the single-page applications they build have the following disadvantages

  • Single-page applications load all resources at one time, so the first blank screen takes a long time
  • Because data is loaded through asynchronous requests, this is not good for SEO

To solve these problems, we can use server-side rendering. With server-side rendering, we couldn’t go back to the old ways, so frameworks like Vue’s next.js and React’s next.js were created. However, as the saying goes, “it is better to teach someone to fish than to teach someone to fish”, we should not only learn to use third-party frameworks, but also learn how they work!

The target

  1. Simple server-side rendering
  2. Routing isomorphism
  3. Store isomorphism
  4. CSS styling
  5. 404 Error Handling

Simple server-side rendering

The server renders, and the server returns the HTML as a string to the front end, which renders. Old server-side rendering is like JSP PHP, refreshing the page on every request. Server rendering now uses the Node middle layer to render HTML instead of the client requesting the data and then sending the content to the client

server

Here we can use renderToString, a method provided by the React-dom under the React-dom /server that returns the component as a string. Unlike renderToStaticMarkup, renderToString returns HTML with data-reactid, which renderToStaticMarkup does not. But starting in Act16, all markup was removed for the sake of simpler HTML, so it’s the same as normal HTML

import React from 'react'; import { renderToString } from 'react-dom/server'; import Header from '.. /components/Header'; export default () => { return ` <! DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, <meta HTTP-equiv =" x-UA-compatible "content=" IE =edge"> <title>Document</title> </head> <body> <div id="app">${renderToString(<Header />)}</div> </body> </html> ` }Copy the code

Express is then used to set up a back-end service to process the request

import express from 'express';
import render from './render';

const app = new express();

app.get(The '*', (req, res) => {
  const html = render();
  res.send(html)
})

app.listen(3000, () = > {console.log('server is running on port 3000');
})
Copy the code

webpack

From the figure above, we can see that the Webpack configuration is divided into server and client. Here we configure the server first, and at the same time separate the same parts of the two into webpack.base.js, and merge them using the webpack-merge plug-in

const path = require('path');
const webpackMerge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base.js');
const serverConfig = {
  target: 'node'.// Exclude node built-in modules, fs, path
  mode: 'development'.entry: './src/server/index.js'.output: {
    filename: 'bundle.js'.path: path.join(__dirname, 'build')},externals: [nodeExternals()] // Exclude the node_modules module
}
module.exports = webpackMerge(baseConfig, serverConfig)
Copy the code

Also, configure.babelrc and package.json. Add the following scripts to pakage.json to listen and compile dynamically

"dev:build:server": "webpack --config ./webpack.server.js --watch"
Copy the code

At this point, NPM run dev:build:server has compiled bundle.js, and our directory structure is as follows

node bundle.js

client

Event binding cannot be handled on the back end, which needs to be handled by the client. We used React16’s new Hydrate for this task, provided by React-DOM. Instead of the render method, it can reuse the server’s incoming content and bind events

import React from 'react';
import ReactDom from 'react-dom';
import Header from '.. /components/Header';

const App = function() {
  return (
      <Header />
  )
}

ReactDom.hydrate(<App />, document.getElementById('app'));
Copy the code

Then add the webpack configuration on the client side to get the public folder and internal index.js via webpack compilation. In order to compile in real time and restart the server immediately after compilation, we need to configure package.json as follows

  "scripts": {
    "dev": "npm-run-all --parallel dev:**"."dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""."dev:build:server": "webpack --config ./webpack.server.js --watch"."dev:build:client": "webpack --config ./webpack.client.js --watch"
  },
Copy the code

In order for the client to function, we need to reference the client compiled index.js with a script in server/render.js and have the server respond to the static resource request

<script src="/index.js"></script>
Copy the code
app.use(express.static('public'));
Copy the code

At this point, we NPM run dev can compile and start the service in parallel, request port 3000, click the button to see the output!

Routing isomorphism

Here we use configuration to build the route

export default[{path: '/'.component: App,
    routes: [{path: '/'.component: Home,
        exact: true // Default route configuration
      },
      {
        path: '/login'.component: Login
      }
    ]
  }
]
Copy the code

Route generation in this form requires the renderRoutes method provided by react-router-config, which eventually converts the route configuration file to the following form

<Switch>
    <Route path="/" component={App} />
    const App = () => {
        <div>
            <Route exact path="/" component={Home} />
            <Route path="/login" component={Login} />
        </div>
    }
</Switch>
Copy the code

In React, a BrowserRouter is normally used for client rendering, while a stateless StaticRouter provided by react-router-dom is used for server rendering. BrowserRouter keeps pages synchronized based on the URL, while StaticRouter just passes in the URL provided by the server so that the route matches

  const App = (
    <StaticRouter location={req.path}>
      <div>
        {renderRoutes(routes)}
      </div>
    </StaticRouter>
  )
Copy the code

Of course, the server side has changed, and for hydrate reuse to work, the client side should be consistent

const App = function() {
  return (
    <BrowserRouter>
      <div>
        { renderRoutes(routes) }
      </div>
    </BrowserRouter>)}Copy the code

To this, our routing isomorphism complete, the client visit http://127.0.0.1:3000/login, you can see the following result

Store isomorphism

To implement the SEO function, the server needs to return an HTML string with data. First, let’s build the store the old-fashioned way

export
It will not be a singleton pattern

export const getClientStore = (a)= > {
  return createStore(
    reducer,
    applyMiddleware(thunk)
  )
}

export const getServerStore = (a)= > {
  return createStore(
    reducer,
    applyMiddleware(thunk)
  )
}
Copy the code

ClientStore and serverStore are then Provider to the client and server sub-components, respectively. The container component is then wired to the Home presentation component through Connect. The following result is displayed after NPM run dev

componentDidMount
dispatch
serverStore
loadData
Promise

Home.loadData = function(store) {
  return store.dispatch(getCommentList())
}
Copy the code

But how does that trigger this method? We can trigger it when we receive the corresponding request, so put it in the routing configuration

  {
    path: '/'.component: Home,
    loadData: Home.loadData,
    exact: true // Default route configuration
  }
Copy the code

Next, we need to trigger loadData based on the route. Here we need to use the matchRoutes method provided by react-router-config. Req. path is used instead of req.url, because req.url takes a query parameter. We then use promise.all to perform all requests, and when all requests are complete, the store will respond with HTML to the client

app.get(The '*', (req, res) => {
  const store = getServerStore()
  const matchedRoutes = matchRoutes(routes, req.path)
  const promises = []
  matchedRoutes.forEach(mRouter= > {
    if(mRouter.route.loadData) {
      promises.push(mRouter.route.loadData(store))
    }
  })
  Promise.all(promises)
    .then(resArr= > {
      const html = render(req,store);
      return res.send(html)
    })
    .catch(err= > {
      console.log('Server error:', err)
    })
})
Copy the code

At this point, we can see that the list data already exists in the server response HTML

Data available -> Blank -> Data available
Initialize clientStore

<script>
    window.__context__ = {state: ${JSON.stringify(store.getState())}}
</script>
Copy the code

Then on getClientStore, initialize the store. CreateStore can pass in three parameters. The second parameter is used to initialize state. When combineReducers is used, its structure must be the same as that of reducer

export const getClientStore = (a)= > {
  const defaultStore = window.__context__ || {}
  return createStore(
    reducer,
    defaultStore.state,
    applyMiddleware(thunk)
  )
}
Copy the code

OK, so there will be no blank flicker interval.

CSS styling

Webpack configuration

The style loader is the plugin that you need to work with CSS styles, but this plugin is not pleasant to play with in the server node environment. We need to use a plug-in specifically designed for server rendering, isomorphic-style-loader, which can be used in its official documentation. First configure webpack.client.js and webpack.server.js. Note that CSS Modules need to be enabled here

  module: {rules: [{test:/\.css$/.use: [
        'isomorphic-style-loader',
        {
            loader: 'css-loader'.options: {
              modules: true // Enable the CSS modularization function}}}}]]Copy the code

The service side

Then, modify render. Js to start with StyleContext

import StyleContext from 'isomorphic-style-loader/StyleContext';
Copy the code

The second step wraps the App with StyleContext. The value property of styleconText.provider receives a context object containing insertCss, which is mainly supplied to the Withstyles mentioned later

  const css = new Set(a)const insertCss = (. styles) = > styles.forEach(style= > css.add(style._getCss()))
  const context = { insertCss }
  const App = (
    <StyleContext.Provider value={context}>
      <Provider store={store}>
        <StaticRouter location={req.path}>
          <div>
            {renderRoutes(routes)}
          </div>
        </StaticRouter>
      </Provider>
    </StyleContext.Provider>
  )
Copy the code

Third, you need to insert the CSS style into the returned HTML template string

<style>${[...css].join('')}</style>
Copy the code

The client

Now that the server has changed, the client needs to follow suit. Let’s change the client/index.jsx. InsertCss is a little different from the server. Node only uses the _getCss method, but _insertCss is used here, which is similar to addStylesToDom of style.loader

import StyleContext from 'isomorphic-style-loader/StyleContext';

const App = function() {
  const insertCss = (. styles) = > {
    const removeCss = styles.map(style= > style._insertCss())
    return (a)= > removeCss.forEach(dispose= > dispose())
  }
  const context = { insertCss }
  return (
    <StyleContext.Provider value={context}>
      <Provider store={getClientStore()}>
        <BrowserRouter>
          <div>
            { renderRoutes(routes) }
          </div>
        </BrowserRouter>
      </Provider>
    </StyleContext.Provider>)}Copy the code

Components use

All configured and ready to use! First, we introduce withStyles, which is a higher-order component with the _insertCss method mentioned above

import withStyles from 'isomorphic-style-loader/withStyles';
Copy the code

Then, import the CSS style and use it. Note that this is not a direct import of ‘./ home.css ‘, but as a module

import style from './Home.css';

<h3 className={style.title}>Home</h3>
Copy the code

Next, we wrap the Home component with withStyles, where the first argument can be passed to the style sequence and the second argument to the component

export default connect(mapStateToProps,
  mapDispatchToProps)(withStyles(style)(Home));
Copy the code

At this point, we get the following result, which shows that the Home title has changed to red

404 Error Handling

Previously, we had isomorphic routing, but when we visited /home, the child page was blank and the response status was 200, which was not correct! We do not have a /home route, although the home page content will appear in /, but the route is /. Therefore, we need to deal with the problem that when there is no route match, we need to respond with 404 not found message. So how do you tell if the requested page doesn’t exist? In this case, we need the context property of StaticRouter. The context passed in can be retrieved in the routing component, we need to put the 404 page at the end, and when the route matches it, we mount the NOT_FOUND variable into the context. So, we can determine if the requested page exists by seeing if the NOT_FOUND variable is present on the context. First, configure the 404 page and add it at the end of the route

  {
    path: The '*'.render: ({staticContext}) = > {
      if (staticContext) staticContext.NOT_FOUND = true
      return <div>404 not found</div>}}Copy the code

We then pass the context to the StaticRouter in render.js

<StaticRouter location={req.path} context={ctx}>
  <div>
    {renderRoutes(routes)}
  </div>
</StaticRouter>
Copy the code

Next, server/index.js determines whether to respond to a 404 error based on whether the NOT_FOUND variable is present

  const context = {}
  const html = render(req, store, context);
  if (context.NOT_FOUND) res.status(404)
  return res.send(html)
Copy the code

Finally, we can request http://127.0.0.1:3000/home see page shows as below

conclusion

Server rendering can optimize the first screen loading speed, but it does not have a significant effect if the data request time is long. Therefore, whether to use server-side rendering also needs to be considered according to the actual application. General server rendering is used in SEO sites, or add, change, delete, check and other business scenes more background management systems. Ps: project address