React simple server rendering example (including the use of redux, redux-thunk)

1. package.json

{
  "name": "react-server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:build:client": "webpack --config config/webpack.client.js --watch",
    "dev:build:server": "webpack --config config/webpack.server.js --watch",
    "dev:start": "nodemon --watch dist --exec node "./dist/bundle.js""
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.0.0",
    "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
    "@babel/preset-env": "^7.22.20",
    "@babel/preset-react": "^7.22.15",
    "@babel/preset-stage-0": "^7.8.3",
    "@babel/preset-typescript": "^7.23.0",
    "@reduxjs/toolkit": "^1.9.7",
    "@types/react": "^18.2.27",
    "@types/react-dom": "^18.2.12",
    "antd": "^5.10.0",
    "autoprefixer": "^9.7.3",
    "axios": "^1.5.1",
    "babel-core": "^7.0.0-bridge.0",
    "babel-loader": "7",
    "clean-webpack-plugin": "^3.0.0",
    "cross-env": "^7.0.3",
    "css-loader": "5.0.0",
    "eslint-loader": "^4.0.2",
    "express-http-proxy": "^2.0.0",
    "file-loader": "^5.0.2",
    "happypack": "^5.0.1",
    "html-webpack-plugin": "^3.2.0",
    "less": "^3.10.3",
    "less-loader": "5.0.0",
    "lodash": "^4.17.15",
    "mini-css-extract-plugin": "^0.8.0",
    "moment": "^2.24.0",
    "node-sass": "^9.0.0",
    "nodemon": "^3.0.1",
    "npm-run-all": "^4.1.5",
    "optimize-css-assets-webpack-plugin": "^5.0.3",
    "postcss": "^8.4.31",
    "postcss-loader": "^3.0.0",
    "postcss-pxtorem": "5.0.0",
    "react-activation": "^0.12.4",
    "react-redux": "^8.1.3",
    "recoil": "^0.7.7",
    "redux": "^4.2.1",
    "redux-persist": "^6.0.0",
    "sass": "^1.69.3",
    "sass-loader": "5.0.0",
    "style-loader": "^1.0.1",
    "terser-webpack-plugin": "^2.2.2",
    "thread-loader": "^4.0.2",
    "typescript": "^5.2.2",
    "url-loader": "^3.0.0",
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.10",
    "webpack-dev-server": "^3.9.0",
    "webpack-merge": "^4.2.2",
    "webpack-node-externals": "^3.0.0",
    "webpack-parallel-uglify-plugin": "^1.1.2",
    "yarn": "^1.22.19"
  },
  "dependencies": {
    "express": "^4.18.2",
    "path": "^0.12.7",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-config": "1.0.0-beta.4",
    "react-router-dom": "4.3.1"
  }
}

2. Create a new .babelrc file

{
    "presets": [
        "@babel/preset-react",
        "@babel/preset-typescript"
    ],
    "plugins": []
}

3. Create a new tsconfig.json file

{
    "compilerOptions": {
      "target": "es5",
      "lib": [
        "dom",
        "dom.iterable",
        "esnext"
      ],
      "allowJs": true,
      "skipLibCheck": true,
      "esModuleInterop": true,
      "allowSyntheticDefaultImports": true,
      "strict": true,
      "forceConsistentCasingInFileNames": true,
      "module": "esnext",
      "moduleResolution": "node",
      "resolveJsonModule": true,
      "isolatedModules": true,
      "noEmit": true,
      "jsx": "react-jsx",
      "noImplicitAny": false
    },
    "include": [
      "src"
    ]
  }

4. config directory

paths.js

const path = require('path');

const srcPath = path.resolve(__dirname, '..', 'src');
const distPath = path.resolve(__dirname, '..', 'dist');

module.exports = {
    srcPath,
    distPath
}

webpack configuration file

(1) webpack.base.js extracts public configuration code

module.exports = {
    //Packaging rules
    module: {
        rules: [
            {
                test: /\.[jt]sx?$/, // Detect file type
                loader: 'babel-loader', // Pay attention to download babel-loader babel-core
                exclude: /node_modules/, // node_modules directory files are not compiled
            }
        ]
    }

}

(2) webpack.server.js server configuration

Use webpack-merge to merge common configuration code

const nodeExternals = require('webpack-node-externals');
const { distPath, srcPath } = require('./paths');
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const serverConfig = {
    mode: 'production', // You can also write development
    target: 'node', // Tell webpack that the packaged code is a server-side file
    entry: './src/server/index.js',
    output: { // Where should the files generated by packaging be placed?
        filename: 'bundle.js',
        path: distPath,
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js'],
        // For third-party modules in npm, the file with the es6 module syntax pointed to by jsnext is preferred.
        mainFields: ['jsnext:main', 'brower', 'main'],
        alias: {
            "components": path.resolve(srcPath, "containers/components"), //Configure a simple path
            "@": srcPath // Change the commonly used directory src to @
        },
    },
    externals: [
        /**
         * 1. How to prevent webpackexternals from affecting the test environment?
            Since webpackexternals excludes some library files from the packaging scope, which may affect the running of unit tests in some cases, you can use webpack-node-externals to exclude all dependencies in the node_modules directory.
         */
        nodeExternals(),
    ],
}

module.exports = merge(config, serverConfig);

(3) webpack.client.js client configuration

Use webpack-merge to merge common configuration code

const { distPath, srcPath, publicPath } = require('./paths');
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const clientConfig = {
    mode: 'production', // You can also write development
    entry: './src/client/index.js',
    output: { // Where should the files generated by packaging be placed?
        filename: 'index.js',
        path: publicPath,
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js'],
        // For third-party modules in npm, the file with the es6 module syntax pointed to by jsnext is preferred.
        mainFields: ['jsnext:main', 'brower', 'main'],
        alias: {
            "components": path.resolve(srcPath, "containers/components"), //Configure a simple path
            "@": srcPath // Change the commonly used directory src to @
        },
    },
}

module.exports = merge(config, clientConfig);

5. src/App.tsx

import React from "react";
import Header from "components/header";
import { renderRoutes } from 'react-router-config'
import _ from 'lodash'

const App = (props) => {
  return (
    <section>
      <Header />
      {/* Display the content corresponding to the page */}
      {renderRoutes(props.route.routes)}
    </section>
  );
};

export default App;

6. src/routes.tsx

Nested routing allows the head component to always exist, and the pages under the head serve as sub-routes.

import React from 'react'
import Home from './containers/home'
import Login from './containers/login'
import App from './App';

const routes = [
    {
      path: '/',
      component:Home,
      loadData: Home.loadData,
      exact: true,
      key: 'home',
   },
    {
        path: '/login',
        component: Login,
        loadData: Login.loadData,
        exact: true,
        key: 'login',
     }
]

export default [{
  path: '/',
  component:App,
  routes,
}]

7. src/store/index.ts

The use of redux-thunk and redux

1) Introduce the required reducer1, reducer2…

2) Use combineReducers to combine the introduced reducer1, reducer2… to form a new reducer

3) Use createStore to create a store instance and pass in the reducer and middleware!!!

createstore syntax:

const store = createStore(reducer, [preloadedState], enhancer);

/**
*createStore accepts 3 parameters:
The first one is reducer,
The second is the initial state,
The third enhancer is the enhancer of the store.
Executing this method returns an object,
Contains five methods: dispatch, subscribe, getState, replaceReducer, and observable.
*/

a. The server obtains the store instance

const store = createStore(
reducer,
applyMiddleware(thunk.withExtraArgument(serverAxios)); // Pay attention to the axios instance passed to the server
);

b. The client obtains the store instance

// Use server data as client data
const defaultState = window.context.state
const store = createStore(
reducer,
defaultState,
// To change the content of the client store, you must use clientAxios
applyMiddleware(thunk.withExtraArgument(clientAxios)) // Pay attention to the axios instance passed to the client
);

import { legacy_createStore as createStore, applyMiddleware, combineReducers } from "redux";
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/home/store'
import clientAxios from '@/client/request'
import serverAxios from '@/server/request'

// Combine the reducers of each module
const reducer = combineReducers({
  home: homeReducer,
});

//Export getStore because we want each user's store to be exclusive!!!
  export const getStore = () => {
    const store = createStore(
      reducer,
      // To change the contents of the server store, you must use serverAxios
      applyMiddleware(thunk.withExtraArgument(serverAxios))
    );
    return store
  }

  export const getClientStore = () => {
    // Use server data as client data
    const defaultState = window.context.state
    const store = createStore(
      reducer,
      defaultState,
      // To change the content of the client store, you must use clientAxios
      applyMiddleware(thunk.withExtraArgument(clientAxios))
    );
    return store
  }

8. src/containers (component page directory)/

home directory

home/index.tsx

import React, {useEffect} from "react";
import { connect } from "react-redux";
import { getHomeList } from './store/actions'
import _ from 'lodash'

// Isomorphism - the same set of react code is executed once on the server and again on the client
const Home = (props) => {
  // If it is a class component, use componentDidMount life cycle
  useEffect(()=> {
    // Avoid calling both the server and the client once on the first screen!!!
    if(!props.list.length) props.getHomeList()
  }, [])

  const getList = () => {
    return <ul>
    {_.map(props.list, (item) => {
      return <li key={item.uid}><span>{item.code}: </span>{item.type}</li>
    })}
  </ul>
  }
  return (
    <section>
      <div>this is {props.name}</div>
      {getList()}
      <button onClick={() => alert(11)}>click</button>
    </section>
  );
};

Home.loadData = (store) => {
  // This function is responsible for loading the data required for this route in advance before server rendering.
  return store.dispatch(getHomeList())
}

const mapStateToProps = (state) => {
  return ({
    name: state.home.name,
    list: state.home.newsList,
  });
}

const mapDispatchToProps = (dispatch) => ({
  getHomeList() {
    console.log('test')
    dispatch(getHomeList())
  }
});
export default connect(mapStateToProps, mapDispatchToProps)(Home);

home/store directory

home/store/index.ts

import reducer from './reducer'

export { reducer }

home/store/actions.ts

import { CHANGE_LIST } from './constants'

const changeList = (list) => ({
    type: CHANGE_LIST,
    list,
})

export const getHomeList = (server) => {
    let url1 = `/app/mock/22915/api/code`
    // const url1 = 'http://rap2api.taobao.org/app/mock/22915/api/code'
    return (dispatch, getState, axiosInstance) => {
        return axiosInstance.post(url1, {
            rows: 10,
            page: 1
        })
            .then(res => {
                const list = res.data.rows;
                dispatch(changeList(list.slice(0, 10)))
            })
    }
}

home/store/constants.ts

//Define constants
export const CHANGE_LIST = 'HOME/CHANGE_LIST'

home/store/reducer.ts

import { CHANGE_LIST } from './constants'

const defaultState = {
    newsList: [],
    name: 'Tom Li-1'
}

const reducer = (state:any = defaultState, action) => {
    switch (action.type) {
      case CHANGE_LIST:
        return {
          ...state,
          newsList: action.list
        };
  
      default:
        return state;
    }
  };

  export default reducer

login/index.tsx

import React from 'react'
import Header from '../components/header';

const Login = () => {
    return <div>
        <Header />
        
        <div>login</div>
        </div>
}

export default Login;

components/public component directory

header/index.tsx

import React from 'react'
import {Link} from 'react-router-dom'

// Isomorphism - the same set of react code is executed once on the server and again on the client
const Header = () => {
    return <div>
        <Link to="/">HOME</Link>
         & amp;nbsp; & amp;nbsp; & amp;nbsp; & amp;nbsp;
        <Link to="/login">LOGIN</Link>
    </div>
}

export default Header

9. src/server (server code directory)/

index.tsx

// Note that the store must use the store defined by the server.

import express from 'express';
import React from 'react';
import { render } from './utils'
import routes from '../routes'
import { getStore } from '../store'
import { matchPath } from 'react-router-dom'
import _ from 'lodash'
import proxy from 'express-http-proxy'

var app = express();

/**
 * Client rendering
 * The react code is executed on the browser, consuming the performance of the user's browser
 *
 * Server rendering
 * The react code is executed on the server and consumes server-side performance (or resources)
 * Error information query website: stackoverflow.com
 */

// When the server requests/api, act as a proxy
app.use('/api', proxy('http://rap2api.taobao.org', {
    proxyReqPathResolver(req) {
        console.log(req.url,'req.url========')
        // const parts = req.url.split('?')
        // const queryString = parts[1]
        // const updatedPath = parts[0].replace(/test/, 'tent');
        return req.url
    }
}));

//As long as it is a static file, search it in the public directory
app.use(express.static('public'));

// * => Any path can lead to the following method
app.get('*', function (req, res) {
    const store = getStore()

    // If you are here, you can get the asynchronous data and fill it into the store
    // We don’t know what is filled in the store. We need to make a judgment based on the current user request address and routing.
    // If the user accesses the root/path, we get the asynchronous data of the home component
    // If the user accesses the /login path, we get the asynchronous data of the login component
    //According to the routing path, add data to and from the store
    const matchedRoutes = [];
    function getRoutes(routes) {
        routes.some(route => {
            const match = matchPath(req.path, route);
            if (match) {
                matchedRoutes.push(route)
            }
            if (Array.isArray(_.get(route, 'routes')) & amp; & amp; _.get(route, 'routes')) {
                getRoutes(_.get(route, 'routes'))
            }
            return match
        })
    }
    getRoutes(routes)
    // Let all components in matchRoutes and the corresponding loadData execute once
    const promises = []
    matchedRoutes.forEach(route => {
        try {
            promises.push(route.loadData(store))
        } catch (error) {
            
        }
    })
    console.log(matchedRoutes, 'matchedRoutes==')
    Promise.all(promises)
        .then(() => {
            res.send(render({store, routes, req}))
        })
})


var server = app.listen(2000);

utils.tsx

The script tag is added because the template string is rendered into dom, and onClick and other events do not respond, so the script tag is isomorphic.

import React from 'react';
import { renderToString } from "react-dom/server"
import { StaticRouter, Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import _ from 'lodash'
import { renderRoutes } from 'react-router-config'

export const render = ({store, routes, req}) => {

    // Virtual dom is a mapping of a JavaScript object of the real dom
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={<!-- -->{}}>
                <div>
                    {renderRoutes(routes)}
                </div>
            </StaticRouter>
        </Provider>
    ))
    //Render the dom when the store data update is completed
    return (
        `<html>
            <head>
                <title>ssr</title>
                <link rel="icon" href="/flower.jpg" />
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                window.context = ${JSON.stringify({state:store.getState()})}
                </script>
                <script src="/index.js"></script>
            </body>
        </html>`
    )
}

request/index.ts

Export server-side axios instance

import axios from "axios";

const instance = axios.create({
    baseURL: 'http://rap2api.taobao.org'
})


export default instance

10. src/client (client code directory)/

index.tsx

// Note that the store must use the store defined by the client

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";
import routes from "../routes";
import { Provider } from "react-redux";
import { getClientStore } from '../store';
import { renderRoutes } from 'react-router-config'

const App = () => {
  return (
    <Provider store={getClientStore()}>
      <BrowserRouter>
        <div>
          {renderRoutes(routes)}
        </div>
      </BrowserRouter>
    </Provider>
  );
};

ReactDOM.hydrate(<App />, document.getElementById("root"));

request/index.ts

Export client axios instance

import axios from "axios";

const instance = axios.create({
    baseURL: '/api'
})


export default instance

11. Because of npm-run-all, executing yarn dev can run the code and monitor whether the component has been modified

Modify the home component and refresh the browser.