🚀 preface

Since the advent of MVVM front-end frameworks such as React Vue Angular, the separation of front and back ends makes the division of labor clearer and development efficiency significantly improved. From the previous back-end rendering data spit page into the front-end request data, rendering page, so in the client rendering must first download the server’S JS CSS file and then render. It takes a while, the white screen in the middle is not very user-friendly, and the page crawler crawls is an empty page with no content, which is not good for SEO. Therefore, SSR on the basis of the front-end framework has become a demand. The benefits of SSR are also clear

2. Speed up the first screen loading, and solve the problem of the first screen blank...Copy the code

There are thousands of articles about React SSR on the Internet. Although the principles are the same, each of them has a different implementation style, and many of them have complex configuration and code logic. Few of them can be explained clearly, so I carefully studied the implementation of React SSR. Inspired by my colleagues, I built my own isomorphic framework of REACT SSR & CSR, with only one purpose, that is to strive to make SSR understandable to everyone.

There are two schematic diagrams in the front. For convenience, I directly used the API of Douban and Nuggets for data display, and the logo was directly used in Douban (don’t mind these details 😂).

🚀 github, the project address is here

The following is a formal introduction to the implementation of SSR in this project

🚗 SSRThe principle of

The objective of the client server isomorphism is about such a process, in general is ten percent between the client and the server node, the function of this layer is to receive the client’s request, in this layer the request data, and then put data into HTML template page, spit out a static page, finally all time-consuming operation is completed on the server. Of course, the server returns only a static HTML, want to interact, DOM operations must run a client js, so when spit out the page to insert the CDN JS, the rest of the client to deal with themselves. So that’s a complete isomorphism. Before I get started, I’m going to throw out a few questions that new people get confused about (actually, I got confused about before).

1. How does the server hijack the client's asynchronous request to complete the request and render the page? 2. Will the data requested by the server be overwritten when running client JS? 3. If the server returns an HTML with no style, it will affect the experience. How to insert style on the server? .Copy the code

With these problems in mind, we began to study the implementation of SSR

📖 Project Structure

First, introduce the project structure

As shown in the figure above, THIS is determined after I tried a variety of structures. I have always attached great importance to the design of the project structure, and a clear framework can make my thinking clearer. Client and server are the contents of client and server respectively. In client, there is our Pages page. Components share components and utils is commonly used tool function. If the project does not need SSR, the client can also run independently, which is the client rendering. The server and client of this project run at port 8987,8988 respectively. The lib folder contains global configurations and services, including Webpack, etc.

When a project is developed or packaged, two compilation pipelines, server and Client, are started

The package files are also stored in the server and client folders under build.

Routing configuration is not posted source code, interested can look at the source code.

📦 webpack

🚀 packaging process is the focus, a Webpack configuration file is universal, some parameters need to be configured depending on client or server, development or production environment.

'use strict'
const path = require('path')
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const StartServerPlugin = require('start-server-webpack-plugin')
const OpenBrowserPlugin = require('open-browser-webpack-plugin')
const nodeExternals = require('webpack-node-externals')
const ManifestPlugin = require('webpack-manifest-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const WebpackBar = require('webpackbar')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// const postcss = require('.. /postcss')
const paths = require('./paths')
const createAlias = require('./alias')
const config = require('.. /package.json')

function createEntry(termimal) {
  const isServer = termimal === 'server'
  const mainEntry = isServer ? paths.appServer : paths.appClient
  returnisServer ? Assign ({}, {main: mainEntry}, {// Common library vendor: ['react'.'react-dom'.'react-router-dom'.'axios']})}function createWebpackConfig (termimal) {
  const isProd = process.env.NODE_ENV === 'production'const isDev = ! isProd const isServer = termimal ==='server'
  const isClient = termimal === 'client'
  const isDevServer = isDev && isServer
  const isProdClient = isProd && isClient
  const isProdServer = isProd && isServer
  const target = isServer ? 'node' : 'web'

  return {
    bail: isProd,
    mode: isProd ? 'production' : 'development',
    target: isServer ? 'node' : 'web',
    entry: createEntry(termimal),
    output: {
      filename: `[name]${isProdClient ? '.[chunkhash]' : ''}.js`,
      // filename: `[name].[chunkhash].js`,
      path: isServer ? paths.buildServer : paths.buildClient,
      publicPath: ' ',
      libraryTarget: isServer ? 'commonjs2' : 'var',
    },
    node: {
      __dirname: true,
      __filename: true
    },
    resolve: {
    // aliasconfigurationalias: createAlias()
    },
    module: {
      strictExportPresence: true,
      noParse (file) {
        return! /\/cjs\/|react-hot-loader/.test(file) && /\.min\.js/.test(file) }, rules: [ { oneOf: [ {test: /\.(js|jsx)? $/, use: [ { loader:'babel-loader',
                  options: {
                    babelrc: false,
                    cacheDirectory: true,
                    compact: isProdClient,
                    highlightCode: truePath. resolve(__dirname,'./babel'), {}]}}]}, {test: /\.css$/,
              use: [
                isClient && (isProd ? MiniCssExtractPlugin.loader : 'style-loader'),
                isDevServer && 'isomorphic-style-loader',
                {
                  loader: 'css-loader',
                  options: {
                    modules: true.localIdentName: '[name]-[local]-[hash:base58:5]',
                    importLoaders: 1,
                    exportOnlyLocals: isProdServer
                  }
                }
              ].filter(Boolean)
            },
            {
              test: /\.(png|jpg|jpeg|gif|image.svg)$/,
              loader: 'file-loader',
              options: {
                name: `${isDev ? '' : '/'}[name].[hash:base58:8].[ext]`,
                emitFile: isClient
              }
            },
            {
              test: /\.svg$/,
              use: [
                {
                  loader: '@svgr/webpack',
                  options: {
                    svgProps: {
                      height: '{props.size || props.height}',
                      width: '{props.size || props.width}',
                      fill: '{props.fill || "currentColor"}'
                    },
                    svgo: false}}]}]}, plugins: [// isDevServer && new StartServerPlugin({name:'main.js',
        keyboard: true,
        signal: true
      }),
      isClient && new HtmlWebpackPlugin(
        Object.assign(
          {},
          {
            inject: true,
            template: paths.appHtml,
          },
          isProd
            ? {
                minify: {
                  removeComments: true,
                  collapseWhitespace: true,
                  removeRedundantAttributes: true,
                  useShortDoctype: true,
                  removeEmptyAttributes: true,
                  removeStyleLinkTypeAttributes: true,
                  keepClosingSlash: true,
                  minifyJS: true,
                  minifyCSS: true,
                  minifyURLs: true,
                },
              }
            : undefined
        )
      ),
      isDev && new webpack.HotModuleReplacementPlugin(),
      new WebpackBar({
        color: isClient ? '#ff2124' : '#1151fe',
        name: isClient ? 'client' : 'server'
      }),
      isProd && new MiniCssExtractPlugin({
        filename: `${isDev ? '' : '/'}[name].[contenthash].css`
      }),
      isClient && new ManifestPlugin({
        writeToFileEmit: true, fileName: 'manifest.json'})]. Filter (Boolean), // Externals: [isServer && nodeExternals()].filter(Boolean), optimization: { minimize: isProdClient, minimizer: [ new TerserPlugin({ cache:true,
          parallel: 2,
          sourceMap: true,
          terserOptions: {
            keep_fnames: /^[A-Z]\w+Error$/,
            safari10: true
          }
        })
      ],
      concatenateModules: isProdClient,
      splitChunks: {
        maxAsyncRequests: 1,
        cacheGroups: isClient ? {
          vendors: {
            test: /node_modules/,
            name: 'vendors',
          }
        } : undefined
      }
    },

    devServer: {
      allowedHosts: [".localhost"].disableHostCheck: false,
      compress: true,
      port: config.project.devServer.port,
      headers: {
        'access-control-allow-origin': The '*'
      },
      hot: false,
      publicPath: ' '.historyApiFallback: true
    }
  }
}

module.exports = createWebpackConfig

Copy the code

📦 above is the webpack configuration of the project, in order to be able to global image

import Avatar from 'components/Avatar'
Copy the code

To reference the component in this way, we need to configure the alias:

'use strict'
const path = require('path')
const base = require('app-root-dir')

module.exports = function createAlias () {
  return Object.assign(
    {},
    {
      'base': base.get(),
      'client':  path.resolve(base.get(), 'client'),
      'server':  path.resolve(base.get(), 'server'),
      'lib':  path.resolve(base.get(), 'lib'),
      'config':  path.resolve(base.get(), 'client/config'),
      'utils':  path.resolve(base.get(), 'client/utils'),
      'hocs':  path.resolve(base.get(), 'client/hocs'),
      'router':  path.resolve(base.get(), 'client/router'),
      'components': path.resolve(base.get(), 'client/components'),
      'pages': path.resolve(base.get(), 'client/pages'),})}Copy the code

Running run dev starts compiling on both the client and server side:

const webpack = require('webpack')
const WebpackDevServer = require('webpack-dev-server')
const open = require('open')
const path = require('path')
const webpackConfig = require(path.resolve('lib/webpackConfig'))
const config = require(path.resolve(__dirname, '.. /package.json'Const clientConfig = webpackConfig()'client') const clientCompiler = webpack(clientConfig) const clientDevServer = new WebpackDevServer( clientCompiler, ClientConfig. DevServer) clientDevServer. Listen (config. Project. DevServer. Port) / / the server compiler const serverConfig = webpackConfig('server')
const serverCompiler = webpack(serverConfig)
serverCompiler.watch({
  quiet: true,
  stats: 'none'
})

Copy the code

💻 Server processing

Here is my server-side processing, where I can use the ES6 module due to the introduction of Babel

import Koa from 'koa'
import path from 'path'
import debug from 'debug'
import Router from 'koa-router'
import koaStatic from 'koa-static'
import bodyParser from 'koa-bodyparser'
import favic from 'koa-favicon'
import packageJson from '.. /package.json'
import ReactServer from './App'
import {routes} from 'client/pages'

const server = new ReactServer()

const log = (target, port) => debug(`dev:${target}  The ${target} side rendering is running at http://localhost:${port}`)

const app = new Koa()
const router = new Router()

app.use(bodyParser({
  jsonLimit: '8mb'})) // Return this page for all routesThe '*'. Async CTX => {// Actual route of matching page const currentRoute = routes.find(r => r.path === ctx.request.url) const currentComponent = CurrentRoute && currentRoute.component // Hijacks requests from the page to the server and sends const {fetchId, GetInitialProps} = currentComponent | | {} const currentProps = getInitialProps && await getInitialProps () / / server requests to the data const contextProps = { [fetchId]: { data: currentProps, pending:false, error: null}} ctx.body = server.renderApp(CTX, contextProps)}) // static app.use(koaStatic(path.join(__dirname, contextProps))'.. /build')))

app.use(
  favic(path.resolve(__dirname, '.. /public/favicon.ico'), { maxAge: 1000 * 60 * 10 }) ); App.use (router.routes()) // Handle server hot reloadif (module.hot) {
  process.once('SIGUSR2', () = > {log('Got HMR signal from webpack StartServerPlugin.')
  })
  module.hot.accept()
  module.hot.dispose(() => server.close())
}

app.listen(packageJson.project.port, () => {
  log('server', packageJson.project.port)(' ')
  log('client', packageJson.project.devServer.port)(' ')})Copy the code

Then configure the asynchronous request in the page:

const fetchId = 'highRateMovie'class HighRateMovie extends React.Component { ...... } HighRateMovie. FetchId = fetchId / / the component under the binding of the asynchronous logic, for the service side fetching HighRateMovie. GetInitialProps = () = > fetch (addQuery ('https://movie.douban.com/j/search_subjects', {
  type: 'movie',
  tag: 'Douban High Score',
  sort: 'recommend',
  page_limit: 40,
  page_start: 0
}))

export default HighRateMovie
Copy the code

FetchId is used as the key of the global context object and cannot be repeated. The resulting data structure in the page will look like this:

{
    movies: [],
    music: {},
    heroList: []
    ...
}

Copy the code

The fetchId here becomes the unique identifier.

Global state Management

React 16.3: contextProps (contextProps); React 16.3: contextProps (contextProps); If you’re not familiar with the Context API, I suggest you look at the documentation of the context API, which is also pretty simple. Why don’t I use Redux or MOBx here? This is purely personal preference. Redux is relatively heavy, and action and reducer need to be configured in the development project, which is cumbersome to write, while MOBx is relatively light. The contextApi is used because it is relatively compact and easy to configure.

Const AppContext = react.createconText (' 'Provider value={this.state}> {this.children} </ appContext.provider > // Provided by the Provider Consumer < appContext. Consumer> {this.props. Children} </ appContext. Consumer>Copy the code

Here is a general idea of how context works. Based on this, a unified APP generator is drawn from the project:

import React from 'react'
import Pages from 'client/pages'
import AppContextProvider from 'hocs/withAppContext'// This is shared by the client and the server, and the context is passed in externally, so we have the global props.export const renderBaseApp = context => {

  return (
    <AppContextProvider appContext={context}>
      <Pages />
    </AppContextProvider>
  )
}

export default renderBaseApp
Copy the code

When rendered, the server grabs the request, stuffs the requested data into the context, and provides it to all components through the Provider.

The so-called isomorphism is that the server spit out an HTML page, but how to execute the page binding events such as clicking? The server does not have the concept of DOM. Therefore, the most important isomorphism is that the spit out HTML still has to load the js packed by the client to complete the binding of related events

import React from 'react'
import path from 'path'
import fs from 'fs'
import Mustache from 'mustache'
import {StaticRouter} from 'react-router-dom'
import {renderToString} from 'react-dom/server'
import { getBuildFile, getAssetPath } from './utils'
import template from './template'
import renderBaseApp from 'lib/baseApp'

letSsrStyles = [] // Create a ReactServer class for the server to call, Class ReactServer {constructor(props) {object.assign (this, props)} // Get all of the packaged files on the clientbuildFiles() {
    returnGetBuildFile ()()} getBuildFile()()vendorFiles() {
    return Object.keys(this.buildFiles).filter(key => {
      const item = this.buildFiles[key]
      return path.extname(item) === '.js'})} // Concatenate script tag string, receive context parameter store data getScripts(CTX) {return this.vendorFiles
    .filter(item => path.extname(item) === '.js')
    .map(item => `<script type="text/javascript" src='${getAssetPath()}${item}'></script>`)
    .reduce((a, b) => a + b, `<script type="text/javascript">window._INIT_CONTEXT_ = ${JSON.stringify(ctx)}</script> ')} // Add CSS files to the server at the beginning of rendering. Since isomorphic-style-loader provides the _getCss() method, the CSS files can be combined into style tags on the server. The resulting page starts with a stylegetCssConst cssFile = fs.readfilesync (path.resolve(__dirname,'.. /client/index.css'), 'utf-8')
    const initStyles = `<style type="text/css">${cssFile}</style>`
    const innerStyles = `<style type="text/css">${ssrStyles.reduceRight((a, b) => a + b, '')}</style> '// Server CSS contains two parts, one is the initialization style file, the other is the CSS modules generated style file, both inserted herereturnInitStyles + innerStyles} // This method is provided with withStyle hoc, AddStyles (CSS) {const styles = typeof css._getcss ==='function' ? css._getCss() : ' '
    if(! ssrStyles.includes(styles)) { ssrStyles.push(css._getCss()) } } renderTemplate = props => {returnMustache.render(template(props)) } renderApp(ctx, Context) {const HTML = renderToString((<StaticRouter location={ctx.url} context={context} {renderBaseApp({renderBaseApp) {renderBaseApp({renderBaseApp) {renderBaseApp({renderBaseApp) {renderBaseApp({renderBaseApp);}} context, addStyles: this.addStyles, ssrStyles: this.ssrStyles})} </StaticRouter> ))return this.renderTemplate({
      title: 'douban', 
      html, 
      scripts: this.getScripts(context), 
      css: this.getCss()
    })
  }
}

export default ReactServer
Copy the code

How does the getCss hook grab styles from my page, thanks to withStyle Hoc:

/** * Only for development */ import React from'react'
import hoistNonReactStatics from 'hoist-non-react-statics'
import { withAppContext } from './withAppContext'

function devWithStyle (css) {
  if(typeof window ! = ='undefined') {
    return x => x
  }

  return function devWithStyleInner (Component) {
    const componentName = Component.displayName || Component.name
    class CSSWrapper extends React.Component {
      render () {

        if (typeof this.props.addStyles === 'function') {
          this.props.addStyles(css)
        }

        return<Component {... this.props} css={css} /> } } hoistNonReactStatics(CSSWrapper, Component) CSSWrapper.displayName = `withStyle(${componentName}) `return withAppContext('addStyles')(CSSWrapper)
  }
}

function prodwithStyle () {
  return x => x
}

const withStyle = process.env.NODE_ENV === 'production' ? prodwithStyle : devWithStyle
export default withStyle

Copy the code

Then introduce in the page:

import React from 'react'
import withStyle from 'hocs/withStyle'
import JumpLink from './JumpLink'
import css from './MovieCell.css'

class MovieCell extends React.Component {
  render() {
    const {data = {}} = this.props
    return (
      <JumpLink href={data.url} blank className={css.root}>
        <img src={data.cover || 'https://img3.doubanio.com/view/photo/s_ratio_poster/public/p480747492.jpg'} className={css.cover} />
        <div className={css.title}>{data.title}</div>
        <div className={css.rate}>{data.rate} 分</div>
      </JumpLink>
    )
  }
}

export default withStyle(css)(MovieCell)
Copy the code

On every insert that uses a style, this hoc grabs the style to the server for processing, which is CSS processing.

You may have noticed that I also inserted such a script when I inserted the client packaged script

<script type="text/javascript">window._INIT_CONTEXT_ = ${JSON.stringify(ctx)}</script>
Copy the code

This is because before isomorphism, the client and the server are two services, so the data cannot be shared. After I send the data to the server, it is initialized by the client in the process of executing the JS of the client. But I clearly have the data.

To solve this problem, we insert an extra script here to store our initialized data. During client rendering, the original context is retrieved directly from the window

class App extends React.Component {
  render() {
    return (
      <BrowserRouter>
        {renderBaseApp(window._INIT_CONTEXT_)}
      </BrowserRouter>
    )
  }
}

export default App
Copy the code

By now our server rendering is almost complete. After starting the service and looking at the page,

Here we can see that the server actually spit out the rendered page directly, while the client render only gets an empty HTML file, and then downloads JS to load the page content. Moreover, due to the Douban and Nuggets APIS I use, the client request is cross-domain, and only the server can get the data. Another benefit of SSR is that since the request is made on the server, the request API is not visible on the page.

At this point we are almost done rendering the server based on the Context API, but there is still a problem. What if I fail to render the server and there is no data to spit out?

So you have to do something special for this situation.

🚄 What can I do if a server request fails

A clientFetch hoc is added here, which is applied to all pages with asynchronous requests. The function of this hoc is that if the client finds no desired data in the process of rendering, the request is judged to have failed and the client requests again.

/** * The client sends request logic when the server fails */ import hoistNonReactStatics from'hoist-non-react-statics'
import {pick} from 'lodash'
import { withAppContext } from 'hocs/withAppContext'Const defaultOptions = {// Trigger client by default on didMount and didUpdate on the browser side:true, // Automatically inject data to props ([fetchId], error, pending), and specify an id fetchId: null}export default function clientFetch (options = {}) {
  options = Object.assign({}, defaultOptions, options)
  const { client: shouldFetch, fetchId } = options

  return function clientFetchInner (Component) {

    if(! Component. The prototype. GetInitialProps) {throw new Error (` getInitialProps must be defined `)} / / inheritance here is that the incoming Component class  clientFetchWrapper extends Component { constructor(props) { super(props) this.getInitialProps = this.getInitialProps.bind(this) } static defaultProps = { [fetchId]: {} }shouldGetInitialProps() {
        return this.props[fetchId].pending === undefined
      }

      componentDidMount () {
        if (typeof super.componentDidMount === 'function') { super.componentDidMount() } this.fetchAtClient() } componentDidUpdate (... args) {if (typeof super.componentDidUpdate === 'function') { super.componentDidUpdate(... Args)} this.fetchatClient ()} // Client isomorphism requestfetchAtClient () {
        if(! shouldFetch) {return
        }
        if (typeof this.shouldGetInitialProps === 'function') {
          if (this.shouldGetInitialProps() && typeof this.getInitialProps === 'function') {this.fetch()}}} // The actual request sending logic of the clientfetch () {
        this.setContextProps({ pending: true })
        return this.getInitialProps()
          .then(data => {
            this.setContextProps({ pending: false, data, error: null })
          }, error => {
            this.setContextProps({ pending: false, data: {}, error})})} // In connect scenarios, inject data into appContextsetContextProps (x) {
        if(! fetchId) {return} this.props.setAppContext(appContext => { const oldVal = appContext[fetchId] || {} const newVal = {[fetchId]: { ... oldVal, ... x }}return newVal
        })
      }

      render () {
        return super.render()
      }
    }

    hoistNonReactStatics(clientFetchWrapper, Component)

    return withAppContext(
      function (appContext) {
        const con = pick(appContext, ['setAppContext'])
        return Object.assign(con, (appContext || {})[fetchId])
      }
    )(clientFetchWrapper)
  }
}

Copy the code

This hoc has two functions: first, the server sends a second request when the request fails to ensure the effectiveness of the page; second, when I do not render the server, I can still package the client file and deploy it online. By default, the request logic of this hoc will be followed. This is equivalent to a layer of insurance. Only here can we truly achieve the client and server isomorphism, and the project needs continuous optimization ~ ~

✨ ✨ ✨

Click a star if you like

react-ssr

If you have any questions, please leave a comment or issue. If there is any improvement in the project, please correct it

In addition, when using douban and Nuggets API, a sudden idea, simple design of their own KOA crawler framework, grab static pages or dynamic API, interested partners can also take a look ~ ~

The crawler firm – spiders