跳到主要内容

webpack-react-javascript

2023年02月24日
柏拉文
越努力,越幸运

一、基础渲染


ReactSSR 基础渲染的过程:

  • 服务端通过react-dom/server中的renderToString() API 将react组件生成字符串
  • 服务端将生成的字符串拼接到html模板中一并发送到前端

具体实现过程如下所示:

1.1 目录结构

|- build // 用于存放打包文件
|- config
| |- webpack.config.js //用于配置 webpack
|- public // 用于存放静态资源 比如 favicon.ico
|- src
| |- assets // 用于存放项目中的静态资源
| |- lib // 用于存放一些处理函数
| |- pages // 项目页面
| |- server // 项目服务端
| |- utils // 项目工具函数
|- babel.config.js //用于配置 Babel

1.2 所需依赖

  • Babel 所需依赖

    yarn add core-js babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/plugin-transform-runtime -D
  • Webpack 所需依赖

    yarn add webpack webpack-cli clean-webpack-plugin -D 
  • Koa 服务所需依赖

    yarn add koa koa-router koa-static -S
  • 命令行 所需依赖

    yarn add nodemon npm-run-all -S 
  • React 所需依赖

    yarn add react react-dom -S

1.3 构建服务

  • 根目录新建src

  • src下创建lib

  • lib下创建ssr.js

    import React from 'react';
    import App from "../pages/App";
    import ReactDOMServer from "react-dom/server";

    export const render = () => {
    const content = ReactDOMServer.renderToString(<App />);
    return content;
    };

    export const buildHtmlString = (content) => {
    const htmlString = `<!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>基于Webpack的React服务端渲染</title>
    </head>
    <body>
    ${content}
    </body>
    </html>`;
    return htmlString;
    };

  • src下创建utils

  • utils下创建path.js

    import Path from 'path';

    export const rootPath=process.cwd();

    export const relativeToRoot=(path)=>{
    return Path.resolve(process.cwd(),path);
    }
  • src下创建server

  • server下创建index.js

    import Koa from 'koa';
    import KoaStatic from 'koa-static'
    import {relativeToRoot} from '../utils/path';

    const app = new Koa();
    app.use(KoaStatic(relativeToRoot('./build')));
    app.use(KoaStatic(relativeToRoot('./public')));
    const requireContext = require.context('./router',false,/.js$/);
    const requireAll = context => context.keys().map(context);
    requireAll(requireContext).forEach(item=>{
    let router=item.default;
    app.use(router.routes()).use(router.allowedMethods());
    });
    app.listen(8090,()=>{
    console.log('渲染服务启动成功!');
    });
  • server下创建router

  • router下创建index.js

    import Router from 'koa-router';
    import Controller from '../controller/index.js'

    const router = new Router();
    router.get(/\.*/,Controller.get);

    export default router;
  • server下创建controller

  • controller下创建index.js

    import {render,buildHtmlString} from '../../lib/ssr'

    class Controller{
    static async get(ctx){
    const content = await render();
    const htmlString = await buildHtmlString(content);
    ctx.body = htmlString;
    }
    }

    export default Controller

1.4 构建页面

  • src下创建pages

  • pages下创建App.js

    import React from 'react';
    import image from '../assets/images/334.jpg'

    function handleClick(){
    console.log('基础渲染详情');
    }

    function App() {
    return (
    <div>
    <h3>基于Webpack的React服务端渲染</h3>
    <div><img src={image}/></div>
    <ul>
    <li>基础渲染 <button onClick={handleClick}>了解详情</button></li>
    </ul>
    </div>
    );
    }

    export default App;

1.5 Webpack 配置

  • 根目录下创建config

  • config下创建webpack.config.js

    const Path = require("path");
    const { CleanWebpackPlugin } = require("clean-webpack-plugin");

    module.exports = {
    target:'node',
    mode: "development",
    entry: Path.resolve(process.cwd(), "./src/server/index.js"),
    output: {
    path: Path.resolve(process.cwd(), "build"),
    filename: "./server/index.js",
    },
    module:{
    rules:[
    {
    test:/\.jsx?$/,
    loader:'babel-loader',
    exclude:/node_modules/,
    },
    {
    test:/.(png|jpe?g|gif|svg)$/i,
    type:'asset',
    generator:{
    filename:'images/[name]-[hash][ext]'
    }
    }
    ]
    },
    plugins:[
    new CleanWebpackPlugin()
    ]
    };

1.6 Babel 额外配置

  • 根目录下创建babel.config.js

    const presets=[
    [
    "@babel/preset-env",
    {
    useBuiltIns: "usage",
    corejs: "3.11.0",
    },
    ],
    "@babel/preset-react"
    ]
    const plugins=["@babel/plugin-transform-runtime"]
    module.exports={
    presets,
    plugins
    }

1.7 package.json 配置

{
"name": "SSR",
"version": "1.0.0",
"main": "index.js",
"repository": "git@github.com:bolawen/SSR.git",
"author": "bolawen <335303383@qq.com>",
"license": "MIT",
"scripts": {
"start": "npm-run-all --parallel server build",
"server": "nodemon --watch build node build/server/index.js",
"build": "webpack --config config/webpack.config.js --watch"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-transform-runtime": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"babel-loader": "^8.2.3",
"clean-webpack-plugin": "^4.0.0",
"core-js": "^3.19.1",
"webpack": "^5.64.0",
"webpack-cli": "^4.9.1"
},
"dependencies": {
"koa": "^2.13.4",
"koa-router": "^10.1.1",
"koa-static": "^5.0.0",
"nodemon": "^2.0.15",
"npm-run-all": "^4.1.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}

二、交互同构


如果服务端直接返回渲染好的html模板,DOM 元素的事件是不会执行的。这是为什么呢?

DOM元素事件是基于浏览器执行的,只有在浏览器端执行了相应的js代码才能绑定事件。

如何解决服务端渲染带来的事件交互缺失的问题呢?

我们需要让代码在浏览器端也执行一次,组件在浏览器端挂载后react会自动完成事件绑定

浏览器也执行一次代码,组件不会重复渲染吗?

浏览器接管页面后,react-dom在渲染组件前会先和页面中的节点做对比,只有对比失败的时候才会采用客户端的内容进行渲染,且react会尽量多的复用已有的节点。

React SSR 交互同构流程:

  • 构建客户端入口文件index.js,入口文件中通过ReactDOM.hyrate()将虚拟DOM转化真实DOM,并且进行双端对比
  • 通过webpack编译客户端入口文件
  • 将编译好的客户端入口文件添加至服务端的html模板中
  • 服务端的html模板增加<div id=root></div> 标签

具体实现过程如下所示:

2.1 目录结构

|- build // 用于存放打包文件
|- config
| |- webpack.config.base.js //用于配置 webpack
| |- webpack.config.client.js // 用于配置客户端 webpack
| |- webpack.config.server.js // 用于配置服务端 webpack
|- public // 用于存放静态资源 比如 favicon.ico
|- src
| |- assets // 用于存放项目中的静态资源\
| |- client // 项目客户端
| |- lib // 用于存放一些处理函数
| |- pages // 项目页面
| |- server // 项目服务端
| |- utils // 项目工具函数
|- babel.config.js //用于配置 Babel

2.2 修改服务

1. 修改 srclib中的ssr.js

title="改动"
  1. 增加<div id='root'></div>

  2. 增加<script src='/client/index.js'></script> ,其中/client/index.js 为后续客户端打包好的入口文件

import React from 'react';
import App from "../pages/App";
import ReactDOMServer from "react-dom/server";

export const render = () => {
const content = ReactDOMServer.renderToString(<App />);
return content;
};

export const buildHtmlString = (content) => {
const htmlString = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基于Webpack的React服务端渲染</title>
</head>
<body>
<div id='root'>${content}</div>
<script src='/client/index.js'></script>
</body>
</html>`;
return htmlString;
};

2.3 修改页面

1. 修改srcpages中的App.js

改动
  1. 增加图片
  2. 增加变大、变小图片事件
import React, { useState } from "react";
import image from "../assets/images/334.jpg";

function App() {
const [style, setStyle] = useState({ width: "200px" });
const handleBig=(step)=>{
const {width} = style;
const widthNum = Number(width.slice(0,3))+step;
setStyle((style)=>{
return {
width:widthNum+'px'
}
});
}
const handleSmall=(step)=>{
const {width} = style;
const widthNum = Math.max(Number(width.slice(0,3))-step,100);
setStyle((style)=>{
return {
width:widthNum+'px'
}
});
}
return (
<div>
<h3>基于Webpack的React服务端渲染</h3>
<div>
<img style={style} src={image} />
</div>
<div>
<button onClick={()=>handleBig(10)}>变大</button>
<button onClick={()=>handleSmall(10)}>变小</button>
</div>
</div>
);
}

export default App;

2.4 构建客户端

1. src下创建client

2. client下创建index.js

细节
  1. 客户端通过ReactDOM.hydrate将虚拟DOM转化为真实DOM,并进行双端对比
import React from 'react';
import App from '../pages/App';
import ReactDOM from 'react-dom';

const render = ()=>{
return <App/>
}

ReactDOM.hydrate(render(),document.getElementById('root'));

2.5 修改 Webpack 配置

1. config下创建webpack.config.base.js

细节
  1. 去掉了clean-webpack-plugin配置
module.exports = {
mode: "development",
module:{
rules:[
{
test:/\.jsx?$/,
loader:'babel-loader',
exclude:/node_modules/,
},
{
test:/.(png|jpe?g|gif|svg)$/i,
type:'asset',
generator:{
filename:'images/[name]-[hash][ext]'
}
}
]
},
};

2. config下创建webpack.config.client.js

细节
  1. 配置客户端静态文件路径publicPath: publicPath:'/' (双端的图片引用路径必须一致)
const Path = require("path");
const Merge = require('webpack-merge');
const Base = require('./webpack.config.base');

const Client = {
entry: Path.resolve(process.cwd(), "./src/client/index.js"),
output: {
publicPath:'/',
filename: "./client/index.js",
path: Path.resolve(process.cwd(), "build"),
},
}

module.exports=Merge.merge(Base,Client);

3. config下创建webpack.config.server.js

细节
  1. 配置服务端静态文件路径publicPath: publicPath:'/' (双端的图片引用路径必须一致)
const Path = require("path");
const Merge = require('webpack-merge');
const Base = require('./webpack.config.base');

const Server = {
target:'node',
entry: Path.resolve(process.cwd(), "./src/server/index.js"),
output: {
publicPath:'/',
filename: "./server/index.js",
path: Path.resolve(process.cwd(), "build"),
},
}

module.exports=Merge.merge(Base,Server);

2.6 修改 package.json

改动
  1. 增加客户端入口文件的打包编译
{
"name": "SSR",
"version": "1.0.0",
"main": "index.js",
"repository": "git@github.com:bolawen/SSR.git",
"author": "bolawen <335303383@qq.com>",
"license": "MIT",
"scripts": {
"start": "npm-run-all --parallel build:* server",
"server": "nodemon --watch build node build/server/index.js",
"build:client": "webpack --config config/webpack.config.client.js --watch",
"build:server": "webpack --config config/webpack.config.server.js --watch"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-transform-runtime": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"babel-loader": "^8.2.3",
"clean-webpack-plugin": "^4.0.0",
"core-js": "^3.19.1",
"webpack": "^5.64.0",
"webpack-cli": "^4.9.1",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"koa": "^2.13.4",
"koa-router": "^10.1.1",
"koa-static": "^5.0.0",
"nodemon": "^2.0.15",
"npm-run-all": "^4.1.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}

三、路由同构


React SSR 双端路由同构过程:

  • 服务端通过react-router-dom@5.3.0中的StaticRouter注册路由
  • 客户端通过react-router-dom@5.3.0中的BrowserRouter注册路由
  • 双端通过react-router-config中的renderRoutes处理数组路由、多级路由
  • 通过props.history.push(path)实现路由的跳转
  • 通过ContextAPI的属性传递处理404状态码

具体实现过程如下所示:

3.1 目录结构

|- build // 用于存放打包文件
|- config
| |- webpack.config.base.js //用于配置 webpack
| |- webpack.config.client.js // 用于配置客户端 webpack
| |- webpack.config.server.js // 用于配置服务端 webpack
|- public // 用于存放静态资源 比如 favicon.ico
|- src
| |- assets // 用于存放项目中的静态资源\
| |- client // 项目客户端
| |- lib // 用于存放一些处理函数
| |- pages // 项目页面
| |- router // 项目路由
| |- server // 项目服务端
| |- utils // 项目工具函数
|- babel.config.js //用于配置 Babel

3.2 所需依赖

1. 增加 React 所需依赖

yarn add react-router-dom@5.3.0 react-router-config -S

3.3 修改服务

1. 修改lib下的ssr.js

改动
  1. 引入react-router-dom中的StaticRouter,通过StaticRouter注册服务端静态路由
  2. 引入react-router-config中的renderRoutes,通过renderRoutes渲染路由页面(同时支持了多级路由渲染)
  3. 传递 locationcontext 属性
import React from "react";
import RouterList from "../router/index";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { renderRoutes } from "react-router-config";

const buildApp = (ctx,context) => {
return (
<StaticRouter location={ctx.request.path} context={context}>
{renderRoutes(RouterList)}
</StaticRouter>
);
};

export const render = (ctx,context) => {
const content = ReactDOMServer.renderToString(buildApp(ctx,context));
return content;
};

export const buildHtmlString = (ctx,content) => {
const htmlString = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基于Webpack的React服务端渲染</title>
</head>
<body>
<div id='root'>${content}</div>
<script src='/client/index.js'></script>
</body>
</html>`;
return htmlString;
};

2. utils下创建index.js

增点
  1. 判断当前运行环境: typeof window === "undefined
export const isServer = typeof window === "undefined";

3. 修改controller下的index.js

改动
  1. 传递 context
  2. 访问未注册路由页面,此时context.NOT_FOUND会有值,所以通过该属性判断,并设置404状态码
import { render, buildHtmlString } from "../../lib/ssr";

class Controller {
static async get(ctx) {
const context = {};
const content = await render(ctx,context);
const htmlString = await buildHtmlString(ctx, content);
if(context.NOT_FOUND){
ctx.response.status=404;
}
ctx.body = htmlString;
}
}

export default Controller;

3.4 修改页面

1. 修改pages下的App.js

改动
  1. 引入react-router-config中的renderRoutes
  2. 引入路由表
  3. renderRoutes渲染好的路由页面放置到适当的布局位置
import React from "react";
import {renderRoutes} from 'react-router-config'

function App(props) {
return (
<div>
{renderRoutes(props.route.routes)}
</div>
);
}

export default App;

2. pages下增加Home

3. Home下创建index.js

增点
  1. 复制 App.js 之前内容
  2. 处理路由跳转事件 props.history.push(path);
import React,{useState} from "react";
import image from "../../assets/images/334.jpg";

function Home(props) {
const [style, setStyle] = useState({ width: "200px" });
const handleBig = (step) => {
const { width } = style;
const widthNum = Number(width.slice(0, 3)) + step;
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleSmall = (step) => {
const { width } = style;
const widthNum = Math.max(Number(width.slice(0, 3)) - step, 100);
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleJump=(path)=>{
props.history.push(path);
}
return (
<div>
<h3>基于Webpack的React服务端渲染</h3>
<div>
<img style={style} src={image} />
</div>
<div>
<button onClick={() => handleBig(10)}>变大</button>
<button onClick={() => handleSmall(10)}>变小</button>
</div>
<div>
<button onClick={()=>handleJump('/about')}>跳转至 About 页面</button>
</div>
</div>
);
}

export default Home;

4. pages下增加About

5. About下创建index.js

import React from "react";

function About(props) {
const handleJump=(path)=>{
props.history.push(path);
}
return (
<div>
<h2>ReactSSR 功能特色</h2>
<ul>
<li>事件交互同构-Home页面图片的变大变小</li>
<li>路由交互同构-Home页面与About页面的切换、404 处理</li>
</ul>
<div>
<button onClick={()=>handleJump('/')}>跳转至 Home 页面</button>
</div>
</div>
);
}

export default About;

6. pages下增加NotFound

7. NotFound下增加index.js

危险
  1. 为服务端传入的context增加NOT_FOUND属性
import React from "react";
import { isServer } from "../../utils";
import image from "../../assets/images/404.png";

function NotFound(props) {
if (isServer && props.staticContext) {
props.staticContext.NOT_FOUND = true;
}
return (
<div>
<img src={image} />
</div>
);
}

export default NotFound;

3.5 修改客户端

1. client下修改index.js

改动
  1. 引入路由表
  2. 引入react-router-dom中的BrowserRouter。通过BrowserRouter注册客户端路由
  3. 引入react-router-config中的renderRoutes。通过renderRoutes渲染路由(支持渲染多级路由)
import React from "react";
import ReactDOM from "react-dom";
import RouterList from '../router/index'
import { BrowserRouter } from "react-router-dom";
import { renderRoutes } from "react-router-config";

const buildApp = () => {
return <BrowserRouter>{renderRoutes(RouterList)}</BrowserRouter>;
};

ReactDOM.hydrate(buildApp(), document.getElementById("root"));

3.6 路由表配置

1. src下创建router

2. router下创建index

import App from "../pages/App";
import Home from "../pages/Home/index";
import About from "../pages/About/index";
import NotFound from "../pages/NotFound/index";

export default [
{
path: "/",
component: App,
routes: [
{
path: "/",
component: Home,
exact: true,
},
{
path: "/about",
component: About,
exact: true,
},
{ component: NotFound },
],
},
];

3.7 package.json 新增的依赖

{
"name": "SSR",
"version": "1.0.0",
"main": "index.js",
"repository": "git@github.com:bolawen/SSR.git",
"author": "bolawen <335303383@qq.com>",
"license": "MIT",
"scripts": {
"start": "npm-run-all --parallel build:* server",
"server": "nodemon --watch build node build/server/index.js",
"build:client": "webpack --config config/webpack.config.client.js --watch",
"build:server": "webpack --config config/webpack.config.server.js --watch"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-transform-runtime": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"babel-loader": "^8.2.3",
"clean-webpack-plugin": "^4.0.0",
"core-js": "^3.19.1",
"webpack": "^5.64.0",
"webpack-cli": "^4.9.1",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"koa": "^2.13.4",
"koa-router": "^10.1.1",
"koa-static": "^5.0.0",
"nodemon": "^2.0.15",
"npm-run-all": "^4.1.5",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-config": "^5.1.1",
"react-router-dom": "^5.3.0"
}
}

四、Redux同构


React SSR 双端Redux同构过程:

  • 客户端通过react-redux中的Provider包裹,并传递新的 store
  • 服务端通过react-redux中的Provider包裹,并传递新的 store
  • 页面正常使用 store 即可

具体实现过程如下所示:

4.1 目录结构

|- build // 用于存放打包文件
|- config
| |- webpack.config.base.js //用于配置 webpack
| |- webpack.config.client.js // 用于配置客户端 webpack
| |- webpack.config.server.js // 用于配置服务端 webpack
|- public // 用于存放静态资源 比如 favicon.ico
|- src
| |- api // 用于请求封装、管理请求 api
| |- assets // 用于存放项目中的静态资源\
| |- client // 项目客户端
| |- lib // 用于存放一些处理函数
| |- pages // 项目页面
| |- router // 项目路由
| |- server // 项目服务端
| |- store // 项目 store
| |- utils // 项目工具函数
|- babel.config.js //用于配置 Babel

4.2 所需依赖

1. 增加react所需依赖

yarn add redux react-redux redux-thunk redux-logger -S 

4.3 修改服务

1. lib下修改ssr.js

危险
  1. 服务端引入react-redux中的Provider
  2. 服务端引入store 中的GetStore 方法: GetStore 用来创建一个新的store对象。通过GetStore方法return createStore()目的是为了用户与store一一对应
import React from "react";
import { Provider } from "react-redux";
import GetStore from "../store/index";
import RouterList from "../router/index";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { renderRoutes } from "react-router-config";

const buildApp = (ctx, context) => {
return (
<Provider store={GetStore()}>
<StaticRouter location={ctx.request.path} context={context}>
{renderRoutes(RouterList)}
</StaticRouter>
</Provider>
);
};

export const render = (ctx, context) => {
const content = ReactDOMServer.renderToString(buildApp(ctx, context));
return content;
};

export const buildHtmlString = (ctx, content) => {
const htmlString = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基于Webpack的React服务端渲染</title>
</head>
<body>
<div id='root'>${content}</div>
<script src='/client/index.js'></script>
</body>
</html>`;
return htmlString;
};

4.4 修改页面

1. 修改Home下的index.js

危险
  1. 引入react-redux中的connect
  2. connect包装Home组件,组成新的函数connectHome
  3. 页面中调用改变state、使用state
import { connect } from "react-redux";
import React, { useState, useEffect } from "react";
import image from "../../assets/images/334.jpg";
import { changeImageListAsync } from "../../store/action/image";

function Home(props) {
const [style, setStyle] = useState({ width: "200px" });
const handleBig = (step) => {
const { width } = style;
const widthNum = Number(width.slice(0, 3)) + step;
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleSmall = (step) => {
const { width } = style;
const widthNum = Math.max(Number(width.slice(0, 3)) - step, 100);
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleJump = (path) => {
props.history.push(path);
};
useEffect(() => {
props.changeImageListAsync({ type: "girl" });
}, []);
return (
<div>
<h3>基于Webpack的React服务端渲染</h3>
<div>
<img style={style} src={image} />
</div>
<div>
<button onClick={() => handleBig(10)}>变大</button>
<button onClick={() => handleSmall(10)}>变小</button>
</div>
<div>
<button onClick={() => handleJump("/about")}>跳转至 About 页面</button>
</div>
<div>
{props.imageList.map((value,index)=>{
return <img style={{width:"150px",height:"100px",objectFit:"cover"}} key={index} src={value}/>
})}
</div>
</div>
);
}

const mapStateToProps = (state) => {
return {
imageList: state.imageList,
};
};
const mapDispatchToProps = {
changeImageListAsync,
};

const connectHome = connect(mapStateToProps, mapDispatchToProps)(Home);

export default connectHome;

4.5 修改客户端

1. 修改client下的index.js

危险
  1. 服务端引入react-redux中的Provider

  2. 服务端引入store 中的GetStore 方法: GetStore 用来创建一个新的store对象。通过GetStore方法return createStore()目的是为了用户与store一一对应

import React from "react";
import ReactDOM from "react-dom";
import GetStore from "../store/index";
import { Provider } from "react-redux";
import RouterList from "../router/index";
import { BrowserRouter } from "react-router-dom";
import { renderRoutes } from "react-router-config";

const buildApp = () => {
return (
<Provider store={GetStore()}>
<BrowserRouter>{renderRoutes(RouterList)}</BrowserRouter>
</Provider>
);
};

ReactDOM.hydrate(buildApp(), document.getElementById("root"));

4.6 Store 模块配置

1. src下创建store

2. store下创建index.js

危险
  1. 通过GetStore函数返回一个新的store对象,目的是为了用户与store一一对应
import Reducer from './reducer';
import Thunk from "redux-thunk";
import Logger from 'redux-logger';
import { createStore,applyMiddleware } from "redux";

const GetStore = ()=>{
return createStore(Reducer,applyMiddleware(Thunk,Logger));
}

export default GetStore;

3. store下创建type.js

export const ChangeToken = "changeToken";
export const ChangeUsername = "changeUsername";
export const ChangeImageList = "changeImageList";

4. store下创建reducer

5. reducer下创建index.js

import { combineReducers } from "redux";
import { UsernameReducer, TokenReducer } from "./user";
import { ImageListReducer } from "./image";

export default combineReducers({
token:TokenReducer,
username:UsernameReducer,
imageList:ImageListReducer
});

6. reducer下创建user.js

import { ChangeUsername, ChangeToken } from "../type";

export const UsernameReducer = (state = "", action) => {
switch (action.type) {
case ChangeUsername:
return action.data;
default:
return state;
}
};

export const TokenReducer = (state = "", action) => {
switch (action.type) {
case ChangeToken:
return action.data;
default:
return state;
}
};

7. reducer下创建image.js

import { ChangeImageList } from "../type";

export const ImageListReducer = (state = [], action) => {
switch (action.type) {
case ChangeImageList:
return action.data;
default:
return state;
}
};

8. store下创建action

9. action下创建user.js

import { ChangeUsername, ChangeToken } from "../type";

export const changeToken = (data) => {
return {
type: ChangeToken,
data,
};
};
export const changeUsername = (data) => {
return {
type: ChangeUsername,
data,
};
};

10. action下创建image.js

import api from "../../api";
import { ChangeImageList } from "../type";

export const changeImageList = (data) => {
return {
type: ChangeImageList,
data,
};
};

export const changeImageListAsync = (playload) => {
return async (dispatch) => {
const { code, data } = await api.findImageList(playload);
if (code == 0) {
dispatch(changeImageList(data));
}
};
};

4.7 请求 模块配置

1. src下创建api

2. api下创建index.js

危险
  1. 将项目请求分为:客户端请求和服务端请求
import { isServer } from "../utils";
import { requestTransform as clientRequest } from "./client";
import { requestTransform as serverRequest } from "./server";

const apiList = {
findImageList:{
url:'/image/find',
method:'get'
},
findByAuthImageList:{
url:'/image/findByAuth',
method:'get'
},
login:{
url:'/user/login',
method:'post'
}
};
const apiRequest = isServer ? serverRequest : clientRequest;

export default apiRequest(apiList);

3. api下创建client.js

import axios from "axios";
import { mapValues, omit } from "lodash";

const request = axios.create({
withCredentials: true,
baseURL: "http://localhost:4000/",
});

request.interceptors.request.use();
request.interceptors.response.use();
export const requestTransform = (config) => {
return mapValues(config, (value) => {
let method;
let url;
if (typeof value === "string") {
url = value;
} else {
url = value.url;
method = value.method;
config = omit(value, ["url", "method"]);
}
method = method || "get";
if (method === "get") {
return function (params) {
return new Promise((resolve, reject) => {
request[method](url, { params, ...config })
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
} else if (method === "post") {
return function (params) {
return new Promise((resolve, reject) => {
request[method](url, params, config)
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
}
});
};

export default request;

4. api下创建server.js

import axios from "axios";
import { mapValues, omit } from "lodash";

const request = axios.create({
withCredentials: true,
baseURL: "http://localhost:4000/",
});

request.interceptors.request.use();
request.interceptors.response.use();
export const requestTransform = (config) => {
return mapValues(config, (value) => {
let method;
let url;
if (typeof value === "string") {
url = value;
} else {
url = value.url;
method = value.method;
config = omit(value, ["url", "method"]);
}
method = method || "get";
if (method === "get") {
return function (params) {
return new Promise((resolve, reject) => {
request[method](url, { params, ...config })
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
} else if (method === "post") {
return function (params) {
return new Promise((resolve, reject) => {
request[method](url, params, config)
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
}
});
};

export default request;

五、预渲染同构


服务端预渲染同构的过程中重要的一步就是脱水与注水的过程,那什么时候会由注水脱水的环节呢?

服务端预渲染通过组件的loadData获取异步数据、更新了服务端store状态。但是此时服务端的store并不会同步到客户端store。需要一下操作来实现:

  • 脱水(dehydrate): 服务端通过loadData获取异步数据之后,经更新的store通过JSON.stringify注入到html模板字符串中。即把服务端的store数据注入到window全局环境中
  • 注水(hydrate): 客户端通过window获取服务端store,作为默认值。即把window上绑定的数据给到客户端的store

React SSR 服务端预渲染同构流程:

  • 给需要预渲染的页面Home增加loadData方法,传入store,调用diapatch,更新state
  • 修改路由表,为Home路由中增加loadData属性
  • ssr.js中引入react-router-config中的matchRoutes 匹配当前ctx.request.path 路由。匹配成功后执行当前路由的loadData方法。在执行loadData方法的过程中,要求loadData自身必须返回一个Promise对象,将loadData的执行过程封装为Promise。再通过Promise.all处理多个组件的loadData方法的执行。目的是为了保证renderToString执行之前数据全部获取完毕。
  • loadData获取的数据通过JSON.stringify() 添加至html模板字符串中,与html一并发送至客户端。
  • store分为服务端store和客户端store。客户端store获取到此时的html模板字符串中的数据,作为state的默认值。

具体实现过程如下所示:

5.1 目录结构

|- build // 用于存放打包文件
|- config
| |- webpack.config.base.js //用于配置 webpack
| |- webpack.config.client.js // 用于配置客户端 webpack
| |- webpack.config.server.js // 用于配置服务端 webpack
|- public // 用于存放静态资源 比如 favicon.ico
|- src
| |- api // 用于请求封装、管理请求 api
| |- assets // 用于存放项目中的静态资源\
| |- client // 项目客户端
| |- lib // 用于存放一些处理函数
| |- pages // 项目页面
| |- router // 项目路由
| |- server // 项目服务端
| |- store // 项目 store
| |- utils // 项目工具函数
|- babel.config.js //用于配置 Babel

5.2 修改页面

1. 修改Home下的index.js

import { connect } from "react-redux";
import React, { useState, useEffect } from "react";
import image from "../../assets/images/334.jpg";
import { changeImageListAsync } from "../../store/action/image";

function Home(props) {
const [style, setStyle] = useState({ width: "200px" });
const handleBig = (step) => {
const { width } = style;
const widthNum = Number(width.slice(0, 3)) + step;
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleSmall = (step) => {
const { width } = style;
const widthNum = Math.max(Number(width.slice(0, 3)) - step, 100);
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleJump = (path) => {
props.history.push(path);
};
useEffect(() => {
props.changeImageListAsync({ type: "girl" });
}, []);
return (
<div>
<h3>基于Webpack的React服务端渲染</h3>
<div>
<img style={style} src={image} />
</div>
<div>
<button onClick={() => handleBig(10)}>变大</button>
<button onClick={() => handleSmall(10)}>变小</button>
</div>
<div>
<button onClick={() => handleJump("/about")}>跳转至 About 页面</button>
</div>
<div>
{props.imageList.map((value, index) => {
return (
<img
style={{ width: "150px", height: "100px", objectFit: "cover" }}
key={index}
src={value}
/>
);
})}
</div>
</div>
);
}

const mapStateToProps = (state) => {
return {
imageList: state.imageList,
};
};
const mapDispatchToProps = {
changeImageListAsync,
};

const ConnectHome = connect(mapStateToProps, mapDispatchToProps)(Home);

ConnectHome.loadData = (store) => {
console.log("服务端预渲染");
return store.dispatch(changeImageListAsync({ type: "girl" }));
};

export default ConnectHome;

5.3 修改服务

1. 修改lib下的ssr.js

危险
  1. 引入react-router-config中的matchRoutes : 用于匹配当前路由ctx.request.path
  2. 此时matchRoutes会返回一个匹配成功的路由数组,如果路由有loadData方法,这个loadData要求返回一个Promise方法,将loadData的执行过程再用一个新的Promise封装。将这些封装好的Promise 通过Promise.all 统一执行
    • 细节一: 将loadData再用Promise封装一层的原因: 无论loadData请求数据成功与失败,新的Promise都要是成功的状态,防止影响Promise.all的执行结果
    • 细节二: 通过Promise.all执行的原因: 保证在renderToString渲染之前数据全部获取完毕
  3. 处理store参数,统一由ssr控制器传入,ssr.js里面不再引入
  4. html模板字符串中添加window.context,为window.context注入store数据
import React from "react";
import { Provider } from "react-redux";
import RouterList from "../router/index";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { renderRoutes, matchRoutes } from "react-router-config";

export const preRender = (ctx, store) => {
const matchedRouter = matchRoutes(RouterList, ctx.request.path);
const promiseArray = [];
matchedRouter.forEach((item) => {
if (item.route.loadData) {
let promise = new Promise((resolve) => {
item.route
.loadData(store)
.then(() => {
resolve();
})
.catch(() => {
resolve();
});
});
promiseArray.push(promise);
}
});
return Promise.all(promiseArray);
};

const buildApp = (ctx, context, store) => {
return (
<Provider store={store}>
<StaticRouter location={ctx.request.path} context={context}>
{renderRoutes(RouterList)}
</StaticRouter>
</Provider>
);
};

export const render = (ctx, context, store) => {
const content = ReactDOMServer.renderToString(buildApp(ctx, context, store));
return content;
};

export const buildHtmlString = (ctx, store, content) => {
const htmlString = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基于Webpack的React服务端渲染</title>
</head>
<body>
<div id='root'>${content}</div>
<script>
window.context={
state:${JSON.stringify(store.getState())}
}
</script>
<script src='/client/index.js'></script>
</body>
</html>`;
return htmlString;
};

5. 修改controller下的index.js

危险
  1. 此处统一引入store,并传入相应函数中
  2. 调用preRender 方法
import { GetServerStore } from "../../store/index";
import { render, preRender, buildHtmlString } from "../../lib/ssr";

class Controller {
static async get(ctx) {
const context = {};
const store = GetServerStore();
await preRender(ctx, store);
const content = await render(ctx, context, store);
const htmlString = await buildHtmlString(ctx, store, content);
if (context.NOT_FOUND) {
ctx.response.status = 404;
}
ctx.body = htmlString;
}
}

export default Controller;

5.4 修改路由表

1. 修改router下的index.js

危险
  1. 为需要预渲染的页面增加loadData属性
import App from "../pages/App";
import Home from "../pages/Home/index";
import About from "../pages/About/index";
import NotFound from "../pages/NotFound/index";

export default [
{
path: "/",
component: App,
routes: [
{
path: "/",
component: Home,
loadData:Home.loadData,
exact: true,
},
{
path: "/about",
component: About,
exact: true,
},
{ component: NotFound },
],
},
];

5.5 修改 Redux

1. 修改store下的index.js

危险
  1. store分为客户端store服务端store
  2. 客户端store添加默认值。默认值为html字符串模板注入的window.context属性
import Reducer from "./reducer";
import Thunk from "redux-thunk";
import Logger from "redux-logger";
import { createStore, applyMiddleware } from "redux";

export const GetServerStore = () => {
return createStore(Reducer, applyMiddleware(Thunk, Logger));
};

export const GetClientStore = () => {
const defaultState = window.context.state;
return createStore(Reducer, defaultState, applyMiddleware(Thunk, Logger));
};

2. 修改action下的image.js

危险
  1. 通过try catch 包裹请求,处理请求中的一切未知错误(路径错误、请求超时等)
  2. 通过Promise 包裹try catch
import api from "../../api";
import { ChangeImageList } from "../type";

export const changeImageList = (data) => {
return {
type: ChangeImageList,
data,
};
};

export const changeImageListAsync = (playload) => {
return (dispatch) => {
return new Promise(async (resolve,reject) => {
try {
const { code, data } = await api.findImageList(playload);
if (code == 0) {
dispatch(changeImageList(data));
resolve();
}else{
reject();
}
} catch (error) {
reject(error);
}
});
};
};

六、Css 样式同构


服务端渲染中为什么会出现页面抖动的问题?

答: 在服务端渲染过程中,服务端返回的是不带任何样式的HTML字符串(也就是说服务端没有处理任何样式)。到了客户端后js代码执行后通过style-loader动态插入到head内,这个过程是从没有样式到有样式的过程,所以会出现页面抖动。

如何解决页面抖动呢?或者说如何优雅的对Css样式同构

答: 在客户端时,加载样式的过程是: css-loadercss编译完成,通过style-loader动态插入到head标签内实现样式的加载。但是在服务端环境下,没有window对象,style-loader不能运行,所以我们通过isomorphic-style-loader 获取到css字符串,同样手动将css字符串插入到html字符串中,将结果一同传送到客户端。

isomorphic-style-loader 具体做了什么呢,他是如何实现的?

答: webpack通过css-loader将导入的css转换成模块对象,所以通过isomorphic-style-loader可以获取样式信息。所以isomorphic-style-loader作用是利用context API,在渲染组件时获取所有的组件样式信息,最终插入到html字符串中。

React SSR 样式同构流程:

  • 服务端经过css-loadersass-loader编译.scss样式文件,利用isomorphic-style-loader 获取样式字符串,通过context传递,最终在renderToString()时可以获得样式字符串并手动插入到head标签中,一并发送给客户端
  • 客户端经过css-loadersass-loader编译.scss样式文件,利用style-loader 自动将样式加入到head标签中

具体实现过程如下所示:

6.1 目录结构

|- build // 用于存放打包文件
|- config
| |- webpack.config.base.js //用于配置 webpack
| |- webpack.config.client.js // 用于配置客户端 webpack
| |- webpack.config.server.js // 用于配置服务端 webpack
|- public // 用于存放静态资源 比如 favicon.ico
|- src
| |- api // 用于请求封装、管理请求 api
| |- assets // 用于存放项目中的静态资源\
| |- client // 项目客户端
| |- hoc // 用于存放高阶组件
| |- lib // 用于存放一些处理函数
| |- pages // 项目页面
| |- router // 项目路由
| |- server // 项目服务端
| |- store // 项目 store
| |- style // 项目样式
| |- utils // 项目工具函数
|- babel.config.js //用于配置 Babel

6.2 所需依赖

  • Webpack 增加所需依赖

    yarn add style-loader css-loader sass sass-loader -D

6.3 修改服务

1. 修改libssr.js

危险
  1. buildHtmlString 接收传入的参数context , 此时context 已经具有了css数组
  2. 处理context中的css数组,通过join() 方法转换为字符串添加到html模板中一并发送到客户端
import React from "react";
import { Provider } from "react-redux";
import RouterList from "../router/index";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { renderRoutes, matchRoutes } from "react-router-config";

export const preRender = (ctx, store) => {
const matchedRouter = matchRoutes(RouterList, ctx.request.path);
const promiseArray = [];
matchedRouter.forEach((item) => {
if (item.route.loadData) {
let promise = new Promise((resolve) => {
item.route
.loadData(store)
.then(() => {
resolve();
})
.catch(() => {
resolve();
});
});
promiseArray.push(promise);
}
});
return Promise.all(promiseArray);
};

const buildApp = (ctx, context, store) => {
return (
<Provider store={store}>
<StaticRouter location={ctx.request.path} context={context}>
{renderRoutes(RouterList)}
</StaticRouter>
</Provider>
);
};

export const render = (ctx, context, store) => {
const content = ReactDOMServer.renderToString(buildApp(ctx, context, store));
return content;
};

export const buildHtmlString = (ctx, store, content,context) => {
const css = context.css.length?context.css.join('\n'):'';
const htmlString = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
${css}
</style>
<title>基于Webpack的React服务端渲染</title>
</head>
<body>
<div id='root'>${content}</div>
<script>
window.context={
state:${JSON.stringify(store.getState())}
}
</script>
<script src='/client/index.js'></script>
</body>
</html>`;
return htmlString;
};

2. 修改controller下的index.js

危险
  1. 修改context结构: 增加css属性,用于存放css数组
  2. context传入到buildHtmlString方法中
import { GetServerStore } from "../../store/index";
import { render, preRender, buildHtmlString } from "../../lib/ssr";

class Controller {
static async get(ctx) {
const context = {css:[]};
const store = GetServerStore();
await preRender(ctx, store);
const content = await render(ctx, context, store);
const htmlString = await buildHtmlString(ctx, store, content,context);
if (context.NOT_FOUND) {
ctx.response.status = 404;
}
ctx.body = htmlString;
}
}

export default Controller;

6.4 修改页面

1. 修改pages下的App.js

危险
  1. 引入App.js 样式
  2. 引入withStyle 中间件: 用于获取css字符串,并加入到context.css 数组中
  3. withStyle 中间件修饰App
import React from "react";
import AppCss from './App.scss';
import withStyle from "../hoc/withStyleHoc";
import {renderRoutes} from 'react-router-config'

function App(props) {
return (
<div id='app'>
{renderRoutes(props.route.routes)}
</div>
);
}

export default withStyle(AppCss)(App);

2. pages下创建App.scss

@import url(../style/index.scss);
#app{
text-align: center;
}

6.5 封装高阶组件

1. src下创建hoc

2. hoc下创建withStyleHoc.js

细节
  1. 判断当前运行环境环境: 通过staticContext来判断是否为服务端
  2. staticContext.css添加当前传入的css样式
import React from "react";

const withStyle = (css) => (DecoratComponent) => {
return function (props) {
const { staticContext } = props;
if (staticContext) {
props.staticContext.css.push(css._getCss());
}
return <DecoratComponent {...props}></DecoratComponent>;
};
};

export default withStyle;

6.6 修改 Webpack 配置

1. 修改configwebpack.config.client.js

改动
  1. 通过css-loadersass-loader 处理.scss样式文件
  2. 通过style-loader 将处理好的样式自动添加到head标签内
const Path = require("path");
const Merge = require('webpack-merge');
const Base = require('./webpack.config.base');

const Client = {
entry: Path.resolve(process.cwd(), "./src/client/index.js"),
output: {
publicPath:'/',
filename: "./client/index.js",
path: Path.resolve(process.cwd(), "build"),
},
module:{
rules:[
{
test:/\.(css|scss)$/,
exclude:/node_modules/,
use:["style-loader","css-loader","sass-loader"]
}
]
}
}

module.exports=Merge.merge(Base,Client);

2. 修改configwebpack.config.server.js

危险
  1. 通过css-loadersass-loader 处理.scss样式文件
  2. css-loader配置: esModule: false 这样做是配置不使用 ES 模块语法,因为css-loaderv4版本开始默认开启了esModule。如果开启了ES模块语法,那么通过isomorphic-style-loader获取到的结果为[Object Module],不是css样式字符串
  3. 通过 isomorphic-style-loader 处理样式文件后会返回三个方法。其中_getCss()可以获取css样式字符串,将css样式字符串通过context 传递到renderToString 方法前。将这些字符传手动加入到head标签内,随html一道发送至客户端。
const Path = require("path");
const Merge = require('webpack-merge');
const Base = require('./webpack.config.base');

const Server = {
target:'node',
entry: Path.resolve(process.cwd(), "./src/server/index.js"),
output: {
publicPath:'/',
filename: "./server/index.js",
path: Path.resolve(process.cwd(), "build"),
},
module:{
rules:[
{
test:/\.(css|scss)$/,
exclude:/node_modules/,
use:["isomorphic-style-loader",{
loader:"css-loader",
options:{
esModule: false,
}
},"sass-loader"]
}
]
}
}

module.exports=Merge.merge(Base,Server);

6.7 package.json 记录新增依赖

{
"name": "SSR",
"version": "1.0.0",
"main": "index.js",
"repository": "git@github.com:bolawen/SSR.git",
"author": "bolawen <335303383@qq.com>",
"license": "MIT",
"scripts": {
"start": "npm-run-all --parallel build:* server",
"server": "nodemon --watch build node build/server/index.js",
"build:client": "webpack --config config/webpack.config.client.js --watch",
"build:server": "webpack --config config/webpack.config.server.js --watch"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-transform-runtime": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
"babel-loader": "^8.2.3",
"clean-webpack-plugin": "^4.0.0",
"core-js": "^3.19.1",
"css-loader": "^6.5.1",
"isomorphic-style-loader": "^5.3.2",
"sass": "^1.43.4",
"sass-loader": "^12.3.0",
"style-loader": "^3.3.1",
"webpack": "^5.64.0",
"webpack-cli": "^4.9.1",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"axios": "^0.24.0",
"koa": "^2.13.4",
"koa-router": "^10.1.1",
"koa-static": "^5.0.0",
"lodash": "^4.17.21",
"nodemon": "^2.0.15",
"npm-run-all": "^4.1.5",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-router-config": "^5.1.1",
"react-router-dom": "^5.3.0",
"redux": "^4.1.2",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.0"
}
}

七、SEO 优化与同构


React SSR对于SEO应该重构哪些点呢?

  • 完善titlemeta Description或者meta keywrods

    title: 搜索结果中的标题(标题越详细,更吸引人)

    description: 搜索结果中的描述
    (但是这两个部分对于 SEO 提升不大)
  • 基于网页的基本组成部分来分析: 文字、媒体、链接

    • 文字:

      1. 原创性: 原创性的文章要比抄袭的文章排名靠前
    • 链接:

      1. 内部链接相关性: 各个链接的相关性越强,排名越靠前
      2. 外部链接数量:外部链接数量越多,排名越靠前

    • 媒体:

      1. 媒体数量: 媒体数量越多,页面内容更丰富,排名越靠前

但是,内容方面的优化我们其实是改变不了的。所以我们基于react-helmet,实现定制的TitleDescription

7.1 所需依赖

1. 安装React所需依赖

yarn add react-helmet -S

7.2 修改页面

1. 修改pages下的App.js

改动
  1. 通过Helmet标签增加titlemeta description
import AppCss from "./App.scss";
import { Helmet } from "react-helmet";
import React, { Fragment } from "react";
import { connect } from "react-redux";
import withStyle from "../hoc/withStyleHoc";
import { findUser } from "../store/action/user";
import { renderRoutes } from "react-router-config";

function App(props) {
return (
<Fragment>
<Helmet>
<title>柏拉图-基于React实现服务端渲染</title>
<meta
name="discription"
content="柏拉图-基于React实现服务端渲染"
></meta>
</Helmet>
<div id="app">{renderRoutes(props.route.routes)}</div>
</Fragment>
);
}

const mapStateToProps = (state) => {
return {};
};
const mapDispatchToProps = {};

const ConnectApp = connect(
mapStateToProps,
mapDispatchToProps
)(withStyle(AppCss)(App));
ConnectApp.loadData = (store) => {
return store.dispatch(findUser());
};

export default ConnectApp;

7.3 修改服务

1. 修改lib下的ssr.js

危险
  1. 通过HelmetrenderStatic方法渲染titlemeta description 字符串
import React from "react";
import { Helmet } from "react-helmet";
import { Provider } from "react-redux";
import RouterList from "../router/index";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import { renderRoutes, matchRoutes } from "react-router-config";

export const preRender = (ctx, store) => {
const matchedRouter = matchRoutes(RouterList, ctx.request.path);
const promiseArray = [];
matchedRouter.forEach((item) => {
if (item.route.loadData) {
let promise = new Promise((resolve) => {
item.route
.loadData(store)
.then(() => {
resolve();
})
.catch(() => {
resolve();
});
});
promiseArray.push(promise);
}
});
return Promise.all(promiseArray);
};

const buildApp = (ctx, context, store) => {
return (
<Provider store={store}>
<StaticRouter location={ctx.request.path} context={context}>
{renderRoutes(RouterList)}
</StaticRouter>
</Provider>
);
};

export const render = (ctx, context, store) => {
const content = ReactDOMServer.renderToString(buildApp(ctx, context, store));
return content;
};

export const buildHtmlString = (ctx, store, content, context) => {
const helmet = Helmet.renderStatic();
const css = context.css.length ? context.css.join("\n") : "";
const htmlString = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${helmet.title.toString()}
${helmet.meta.toString()}
<style>
${css}
</style>
<title>基于Webpack的React服务端渲染</title>
</head>
<body>
<div id='root'>${content}</div>
<script>
window.context={
state:${JSON.stringify(store.getState())}
}
</script>
<script src='/client/index.js'></script>
</body>
</html>`;
return htmlString;
};

八、中间层代理与登录认证


客户端进行登录操作,服务端返回cookie之后,浏览器存储了cookie,登录成功。但是当用户刷新页面的时候,此时页面通过服务端进行渲染并发起请求,但是此时服务端并没有cookie,所以就直接当没有登录的逻辑处理了。那么如何解决服务端请求认证呢?

答: 再次刷新页面时,通过ctx.cookies.get()可以获取到来自客户端的cookie信息。将ctx传递给服务端的store,通过redux-thunkwithExtraArgument传递给axios请求,添加到headers中。通过传递ctx实现了客户端的Cookie在服务端中使用。

React SSR 登录认证流程:

  • 第一次打开页面或者刷新页面时:

    1. 点击去登录按钮,输入用户名和密码进行登录认证,登录接口在客户端的Cookie中种下token信息
    2. 客户端的Cookie---> 服务端接收 --> 服务端redux-thunk中的withExtraArgument 传递ctx --> 服务端请求携带Cookie
    3. App.js 调用loadData 进行服务端预渲染--预渲染用户信息

具体实现过程如下所示:

8.1 目录结构

|- build // 用于存放打包文件
|- config
| |- webpack.config.base.js //用于配置 webpack
| |- webpack.config.client.js // 用于配置客户端 webpack
| |- webpack.config.server.js // 用于配置服务端 webpack
|- public // 用于存放静态资源 比如 favicon.ico
|- src
| |- api // 用于请求封装、管理请求 api
| |- assets // 用于存放项目中的静态资源\
| |- client // 项目客户端
| |- hoc // 用于存放高阶组件
| |- lib // 用于存放一些处理函数
| |- pages // 项目页面
| |- router // 项目路由
| |- server // 项目服务端
| |- store // 项目 store
| |- style // 项目样式
| |- utils // 项目工具函数
|- babel.config.js //用于配置 Babel

8.2 所需依赖

  • 安装Cookie依赖,便于操作Cookie

    yarn add js-cookie -S
  • 安装qs依赖,便于操作查询字符串

    yarn add qs -S

8.3 修改页面

1. 修改pages下的App.js

危险
  1. 引入react-redux中的connect方法,装饰App组件。使App组件可以共享store
  2. ConnectApp(通过 connect 装饰后的组件) 添加loadData方法,实现App组件的服务端预渲染。预渲染的数据为用户信息
  3. 通过用户信息的服务端预渲染,实现了每一次刷新---服务端获取首先获取用户信息的逻辑
import AppCss from "./App.scss";
import { connect } from "react-redux";
import React from "react";
import withStyle from "../hoc/withStyleHoc";
import { renderRoutes } from "react-router-config";
import { findUser } from "../store/action/user";

function App(props) {
return <div id="app">{renderRoutes(props.route.routes)}</div>;
}

const mapStateToProps = (state) => {
return {};
};
const mapDispatchToProps = {};

const ConnectApp = connect(
mapStateToProps,
mapDispatchToProps
)(withStyle(AppCss)(App));
ConnectApp.loadData = (store) => {
return store.dispatch(findUser());
};

export default ConnectApp;
@import url(../style/index.scss);
#app{
width:100%;
height:100%;
text-align: center;
}

2. 修改Home下的index.js

危险
  1. 增加信息控制逻辑:
    • 如果已经登录: 显示信息
    • 如果没有登录: 显示跳转登录页按钮,并且添加redirect参数,登录后重新回到该页面
import api from "../../api/";
import { connect } from "react-redux";
import image from "../../assets/images/334.jpg";
import React, { useState, useEffect } from "react";
import { changeImageListAsync } from "../../store/action/image";

function Home(props) {
const [style, setStyle] = useState({ width: "200px" });
const [girlList, setGirlList] = useState([]);
const handleBig = (step) => {
const { width } = style;
const widthNum = Number(width.slice(0, 3)) + step;
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleSmall = (step) => {
const { width } = style;
const widthNum = Math.max(Number(width.slice(0, 3)) - step, 100);
setStyle((style) => {
return {
width: widthNum + "px",
};
});
};
const handleJump = (path) => {
props.history.push(path);
};
useEffect(() => {
props.changeImageListAsync({ type: "other" });
if (props.user.username) {
api.findByAuthImageList({ type: "girl" }).then((res) => {
const { code, data } = res;
if (code == 0) {
setGirlList(() => {
return data;
});
}
});
}
}, []);
return (
<div>
<h3>基于Webpack的React服务端渲染</h3>
<div>
<img style={style} src={image} />
</div>
<div>
<h3>交互——同构</h3>
<button onClick={() => handleBig(10)}>变大</button>
<button onClick={() => handleSmall(10)}>变小</button>
</div>
<div>
<h3>路由——同构</h3>
<button onClick={() => handleJump("/about")}>跳转至 About 页面</button>
</div>
<div>
<h3>服务端预渲染(包含 redux )——同构</h3>
{props.imageList.map((value, index) => {
return (
<img
style={{ width: "150px", height: "100px", objectFit: "cover" }}
key={index}
src={value}
/>
);
})}
</div>
<div>
<h3>中间层代理与登录认证——同构</h3>
{props.user.username ? (
girlList.map((value, index) => {
return (
<img
style={{ width: "150px", height: "100px", objectFit: "cover" }}
key={index}
src={value}
/>
);
})
) : (
<button onClick={() => handleJump("/login?redirect=/")}>
登录后可查看图片
</button>
)}
</div>
</div>
);
}

const mapStateToProps = (state) => {
return {
user: state.user,
imageList: state.imageList,
};
};
const mapDispatchToProps = {
changeImageListAsync,
};

const ConnectHome = connect(mapStateToProps, mapDispatchToProps)(Home);

ConnectHome.loadData = (store) => {
return store.dispatch(changeImageListAsync({ type: "other" }));
};

export default ConnectHome;

3. pages下创建login

4. login下创建index.js

危险
  1. 构建login登录表单
  2. 引入withStyle高阶组件,用来装饰Login组件,从而实现Login的样式同构
  3. 引入react-redux中的connect方法,用来装饰Login,从而使Login可以共享store
  4. 引入store中的loginfindUser方法,实现Login的 登录->获取用户信息 流程
  5. 引入react-router中的Redirect组件,实现登录成功后的重定向
  6. 引入qs中的parse方法,用来处理当前url中的查询字串,记录重定向路径。比如login?redirect=/about
import {parse} from 'qs';
import LoginCss from "./index.scss";
import { connect } from "react-redux";
import React, { useState } from "react";
import { Redirect } from "react-router";
import withStyle from "../../hoc/withStyleHoc";
import { login, findUser } from "../../store/action/user";

function Login(props) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const handleLogin = async () => {
await props.login({ username, password });
await props.findUser();
};
const handleChange = (field, e) => {
const fields = {
username: setUsername,
password: setPassword,
};
fields[field](() => {
return e.target.value;
});
};
return (
<div className="login">
{!props.user.username ? (
<div className="login-form">
<div className="login-form-item">
<label>用户名</label>
<input
value={username}
name="username"
placeholder="请输入用户名"
onChange={(e) => handleChange("username", e)}
/>
</div>
<div className="login-form-item">
<label>密码</label>
<input
value={password}
name="password"
placeholder="请输入密码"
onChange={(e) => handleChange("password", e)}
/>
</div>
<div className="login-Btn" onClick={() => handleLogin()}>
登录
</div>
</div>
) : (
<Redirect to={parse(props.location.search.slice(1)).redirect||'/'} />
)}
</div>
);
}

const mapStateToProps = (state) => {
return {
user: state.user,
};
};
const mapDispatchToProps = {
login,
findUser,
};

const ConnectLogin = connect(
mapStateToProps,
mapDispatchToProps
)(withStyle(LoginCss)(Login));

export default ConnectLogin;
label {
width: 100px;
margin-right: 8px;
text-align: right;
display: inline-block;
}
.login{
width:100%;
height:100%;
display: flex;
align-items: center;
justify-content: center;
}
.login-form-item{
margin-bottom:10px;
}
.login-Btn {
width: 100px;
height: 44px;
margin: 0 auto;
cursor: pointer;
line-height: 44px;
border-radius: 4px;
background-color: antiquewhite;
}

8.4 修改路由表

1. 修改router下的index.js

危险
  1. App 路由增加loadData属性
  2. 增加登录页路由
import App from "../pages/App";
import Home from "../pages/Home/index";
import About from "../pages/About/index";
import Login from "../pages/Login/index";
import NotFound from "../pages/NotFound/index";

export default [
{
path: "/",
component: App,
loadData: App.loadData,
routes: [
{
path: "/",
component: Home,
loadData: Home.loadData,
exact: true,
},
{
path: "/about",
component: About,
exact: true,
},
{
path: "/login",
component: Login,
exact: true,
},
{ component: NotFound },
],
},
];

8.5 配置公共样式

1. src下创建style

2. style下创建index.scss

html{
width:100%;
height:100%;
margin:0;
padding:0;
}

body{
width:100%;
height:100%;
margin:0;
padding:0;
}

ul,li{
list-style: none;
}

#root{
width:100%;
height:100%;
}

8.6 修改服务端配置

1. 修改controller下的index.js

危险
  1. GetServerStore 方法中传入ctx
import { GetServerStore } from "../../store/index";
import { render, preRender, buildHtmlString } from "../../lib/ssr";

class Controller {
static async get(ctx) {
const context = { css: [] };
const store = GetServerStore(ctx);
await preRender(ctx, store);
const content = await render(ctx, context, store);
const htmlString = await buildHtmlString(ctx, store, content, context);
if (context.action === "REPLACE") {
}
if (context.NOT_FOUND) {
ctx.response.status = 404;
}
ctx.body = htmlString;
}
}

export default Controller;

8.7 修改 Redux 配置

1. 修改store下的index.js

危险
  1. 借助Thunk中的withExtraArgument方法,向下传递ctx参数
import Reducer from "./reducer";
import Thunk from "redux-thunk";
import Logger from "redux-logger";
import { createStore, applyMiddleware } from "redux";

export const GetServerStore = (ctx) => {
return createStore(Reducer, applyMiddleware(Thunk.withExtraArgument(ctx), Logger));
};

export const GetClientStore = () => {
const defaultState = window.context.state;
return createStore(Reducer, defaultState, applyMiddleware(Thunk, Logger));
};

2. 修改store下的type.js

危险
  1. 重构user Reducer 结构
export const ChangeUser = "changeUser";
export const ChangeImageList = "changeImageList";

3. 修改reducer下的index.js

危险
  1. 重构user Reducer 结构
import { combineReducers } from "redux";
import { ImageListReducer } from "./image";
import { UserReducer } from "./user";

export default combineReducers({
user: UserReducer,
imageList: ImageListReducer,
});

4. 修改reducer下的user.js

危险
  1. 重构user Reducer 结构
import { ChangeUser } from "../type";

export const UserReducer = (state = {}, action) => {
switch (action.type) {
case ChangeUser:
return action.data;
default:
return state;
}
};

5. 修改action下的user.js

危险
  1. 重构user Reducer 结构
  2. 通过try catch 包裹请求,用于处理未知错误,防止报错
  3. 通过Promise 包裹try catch
import api from "../../api/index";
import { ChangeUser } from "../type";

export const changeUser = (data) => {
return {
type: ChangeUser,
data,
};
};
export const login = (playload) => {
return (dispatch) => {
return new Promise(async (resolve, reject) => {
try {
const { code } = await api.login(playload);
if (code == 0) {
resolve();
} else {
reject();
}
} catch (error) {
reject();
}
});
};
};
export const findUser = () => {
return (dispatch,state,ctx) => {
return new Promise(async (resolve, reject) => {
try {
const {code,data} =await api.findUser({},ctx);
if(code==0){
dispatch(changeUser({username:data}));
resolve();
}else{
reject();
}
}catch(error){
reject();
}
});
};
};

8.8 修改请求模块配置

1. 修改api下的index.js

危险
  1. 增加登录接口
  2. 增加获取用户信息接口
import { isServer } from "../utils";
import { requestTransform as clientRequest } from "./client";
import { requestTransform as serverRequest } from "./server";

const apiList = {
findImageList:{
url:'/image/find',
method:'get'
},
findByAuthImageList:{
url:'/image/findByAuth',
method:'get'
},
login:{
url:'/userByJwt/login',
method:'post'
},
findUser:{
url:'/userByJwt/find',
method:'get'
}
};
const apiRequest = isServer ? serverRequest : clientRequest;

export default apiRequest(apiList);

2. 修改api下的client.js

危险
  1. Cookie中获取token信息
  2. token信息配置到headersAuthorization
import axios from "axios";
import { mapValues, omit } from "lodash";
import { getCookie } from "../utils/cookie";

const request = axios.create({
withCredentials: true,
baseURL: "http://localhost:4000/",
// baseURL:'https://bolawen.com/server'
});

request.interceptors.request.use(
(config) => {
const token = getCookie("token");
if(token){
config.headers.common['Authorization'] = 'Bearer ' + token;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
request.interceptors.response.use();
export const requestTransform = (config) => {
return mapValues(config, (value) => {
let method;
let url;
if (typeof value === "string") {
url = value;
} else {
url = value.url;
method = value.method;
config = omit(value, ["url", "method"]);
}
method = method || "get";
if (method === "get") {
return function (params) {
return new Promise((resolve, reject) => {
request[method](url, { params, ...config })
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
} else if (method === "post") {
return function (params) {
return new Promise((resolve, reject) => {
request[method](url, params, config)
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
}
});
};

export default request;

3. 修改api下的server.js

危险
  1. 服务端通过传递下来的ctx获取客户端的Cookies信息
  2. Cookie信息配置到headers中的Authorization字段
import axios from "axios";
import { mapValues, omit } from "lodash";

const request = axios.create({
withCredentials: true,
baseURL: "http://localhost:4000/",
// baseURL:'https://bolawen.com/server'
});

request.interceptors.request.use(
(config) => {
const { token } = config;
if (token) {
config.headers.common["Authorization"] = "Bearer " + token;
}
return config;
},
(error) => {}
);
request.interceptors.response.use();
export const requestTransform = (config) => {
return mapValues(config, (value) => {
let method;
let url;
if (typeof value === "string") {
url = value;
} else {
url = value.url;
method = value.method;
config = omit(value, ["url", "method"]);
config.headers = {};
}
method = method || "get";
if (method === "get") {
return function (params, ctx) {
config.token = ctx.cookies.get("token");
return new Promise((resolve, reject) => {
request[method](url, { params, ...config })
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
} else if (method === "post") {
return function (params, ctx) {
config.token = ctx.cookies.get("token");
return new Promise((resolve, reject) => {
request[method](url, params, config)
.then((result) => {
resolve(result.data);
})
.catch((error) => {
reject(error);
});
});
};
}
});
};

export default request;