react简单的服务器渲染示例(含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. 新建.babelrc文件

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

3. 新建tsconfig.json文件

{
    "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目录

paths.js

const path = require('path');

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

module.exports = {
    srcPath,
    distPath
}

webpack配置文件

(1) webpack.base.js提取公共配置代码

module.exports = {
    // 打包的规则
    module: {
        rules: [
            {
                test: /\.[jt]sx?$/, // 检测文件类型
                loader: 'babel-loader', // 注意下载babel-loader babel-core
                exclude: /node_modules/, // node_modules目录文件不编译
            }
        ]
    }

}

(2) webpack.server.js服务器配置

使用webpack-merge合并公共配置代码

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', // 也可以写development
    target: 'node', // 告诉webpack打包的代码是服务器端文件
    entry: './src/server/index.js',
    output: { // 打包生成的文件应该放到哪儿去
        filename: 'bundle.js',
        path: distPath,
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js'],
        // 针对npm中的第三方模块优先采用jsnext中指向的es6模块语法的文件
        mainFields: ['jsnext:main', 'brower', 'main'],
        alias: {
            "components": path.resolve(srcPath, "containers/components"), //配置样式简单的路径
            "@": srcPath // 把 src 这个常用目录修改为 @
        },
    },
    externals: [
        /**
         * 1、如何让webpackexternals不影响测试环境?
            由于webpackexternals将部分库文件排除在打包范围之外,这样在某些情况下可能会影响单元测试的运行,可以使用webpack-node-externals来排除node_modules目录下的所有依赖项。
         */
        nodeExternals(),
    ],
}

module.exports = merge(config, serverConfig);

(3) webpack.client.js客户端配置

使用webpack-merge合并公共配置代码

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', // 也可以写development
    entry: './src/client/index.js',
    output: { // 打包生成的文件应该放到哪儿去
        filename: 'index.js',
        path: publicPath,
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js'],
        // 针对npm中的第三方模块优先采用jsnext中指向的es6模块语法的文件
        mainFields: ['jsnext:main', 'brower', 'main'],
        alias: {
            "components": path.resolve(srcPath, "containers/components"), //配置样式简单的路径
            "@": srcPath // 把 src 这个常用目录修改为 @
        },
    },
}

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 />
      {/* 显示页面对应的内容 */}
      {renderRoutes(props.route.routes)}
    </section>
  );
};

export default App;

6. src/routes.tsx

嵌套路由, 让head组件一直存在, head之下的页面作为子路由

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

redux-thunk和redux的使用

1) 引入需要的reducer1, reducer2...

2) 使用combineReducers组合引入的reducer1, reducer2..., 构成新的reducer

3) 使用createStore创建store实例, 传入reducer和中间件!!!

createstore语法:

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

/**
*createStore接受3个参数:
第一个是reducer,
第二个是初始的state, 
第三个enhancer是store的增强器。 
执行本方法返回一个对象,
包含dispatch,subsribe,getState,replaceReducer,observable五个方法。
*/

a. 服务端获取store实例

const store = createStore(
      reducer, 
      applyMiddleware(thunk.withExtraArgument(serverAxios)); // 注意传服务端的axios实例
    );

b. 客户端获取store实例

        // 拿服务端的数据作为客户端的数据
    const defaultState = window.context.state
    const store = createStore(
      reducer, 
      defaultState, 
      // 改变客户端store的内容, 那么一定要使用clientAxios
      applyMiddleware(thunk.withExtraArgument(clientAxios)) // 注意传客户端的axios实例
    );

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'

// 将各个模块的reducer组合一下
const reducer = combineReducers({
  home: homeReducer,
});

// 导出getStore是因为想要每个用户的store都是独享的!!!
  export const getStore = () => {
    const store = createStore(
      reducer, 
      // 改变服务器store的内容, 那么一定要使用serverAxios
      applyMiddleware(thunk.withExtraArgument(serverAxios))
    );
    return store
  }

  export const getClientStore = () => {
    // 拿服务端的数据作为客户端的数据
    const defaultState = window.context.state
    const store = createStore(
      reducer, 
      defaultState, 
      // 改变客户端store的内容, 那么一定要使用clientAxios
      applyMiddleware(thunk.withExtraArgument(clientAxios))
    );
    return store
  }

8. src/containers(组件页面目录)/

home目录

home/index.tsx

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

// 同构--同一套react代码, 在服务器执行一次, 在客户端再执行一次
const Home = (props) => {
  // 如果是class组件使用componentDidMount生命周期
  useEffect(()=> {
    // 避免首屏时服务端和客户端都各自调用了一次!!!
    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) => {
  // 这个函数, 负责在服务器渲染之前, 把这个路由需要的数据提前加载好
  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目录

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

// 定义常量
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/公共组件目录

header/index.tsx

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

// 同构--同一套react代码, 在服务器执行一次, 在客户端再执行一次
const Header = () => {
    return <div>
        <Link  to="/">HOME</Link>
        &nbsp;&nbsp;&nbsp;&nbsp;
        <Link to="/login">LOGIN</Link>
    </div>
}

export default Header

9. src/server(服务端代码目录)/

index.tsx

// 注意store要使用服务端定义的store

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();

/**
 * 客户端渲染
 * react代码在浏览器上执行, 消耗的是用户浏览器的性能
 * 
 * 服务器渲染
 * react代码在服务器上执行, 消耗的是服务器端的性能(或者资源)
 * 报错信息查询网站: stackoverflow.com
 */

// 服务器请求/api的时候, 做代理
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
    }
}));

// 只要是静态文件, 都到public目录找
app.use(express.static('public'));

// * => 任意路径都能走到下列的方法
app.get('*', function (req, res) {
    const store = getStore()

    // 如果在这里, 能够拿到异步数据并填充到store之中
    // store里面到底填充什么, 我们不知道, 我们需要结合当前用户请求地址, 和路由, 做判断
    // 如果用户访问根/路径, 我们就拿home组件的异步数据
    // 如果用户访问/login路径, 我们就拿login组件的异步数据
    // 根据路由的路径, 来往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')) && _.get(route, 'routes')) {
                getRoutes(_.get(route, 'routes'))
            }
            return match
        })
    }
    getRoutes(routes)
    // 让matchRoutes里面所有的组件, 对应的loadData执行一次
    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

添加script标签是因为模板字符串渲染成dom, onClick等事件没有反应, 所以script标签再同构一下

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}) => {

    // 虚拟dom是真实dom的一个JavaScript对象的映射
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                <div>
                    {renderRoutes(routes)}
                </div>
            </StaticRouter>
        </Provider>
    ))
    // 当store数据更新完成再渲染dom
    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

导出服务端axios实例

import axios from "axios";

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


export default instance

10. src/client(客户端代码目录)/

index.tsx

// 注意store要使用客户端定义的store

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

导出客户端axios实例

import axios from "axios";

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


export default instance

11. 因为npm-run-all, 所以执行yarn dev就能运行代码并监听组件是否修改了

修改home组件, 刷新浏览器就行了

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值