# React-SSR

  1. egg官网文档 (opens new window)
  2. npm-run-all (opens new window)
  3. React服务端渲染与同构实践 (opens new window)
  4. React 中同构(SSR)原理脉络梳理 (opens new window)
  5. React 同构实践 (opens new window)
  6. koa状态码 (opens new window)

# 1、简介

# 1、csr与ssr

  • SSR:在服务端,把页面所有的内容都初始化好后,将整个页面的HTML丢到浏览器,给浏览器渲染,浏览器渲染的是整个页面的内容,页面的任何操作都会引起页面的刷新,服务端重新生成页面然后返回整个html文件给客户端
    • 优点
      • 更好的首屏性能,不需要提前下载一堆css和js后才可看到页面
      • 更利于SEO,蜘蛛可以直接抓取已渲染的内容
    • 缺点
      • 每次页面的改变都需要服务端把所有的代码都初始化,页面需刷新后重新展示
  • CSR:服务器只返回必要的html(只有一个框架和占位节点)和js资源,js在客户端解析、执行然后渲染生成页面,同时js执行会完成页面事件的绑定,优点是前后端分离,页面交互较好
  • SSR + SPA:服务端渲染返回首屏,客户端渲染进行aqs88saa交互。
    • 既能满足首屏的性能和SEO,又提高了后续页面操作性能和体验
    • 前后端分离开发:前端注重动画等渲染,后端注重数据及性能

# 2、同构

一般是指服务端和客户端同构,意思是服务端和客户端运行同一套代码程序

  • 服务端执行:让 React 代码在服务器端先执行一次,使得用户下载的 HTML 已经包含了所有的页面展示内容

  • 客户端执行:React代码在客户端再次执行,为 HTML 网页中的内容添加数据及事件的绑定,页面就具备了 React 的各种交互能力

# 3、使用

  • 分布式,负载均衡,java较成熟,node暂无成熟方案

  • 内部系统搭建使用nodejs,eg:淘宝广告页等

# 2、代码解析

# 1、核心代码

  • ReactDOMServer.renderToString(element)

    服务端渲染,将React元素渲染为初始HTML,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。

  • ReactDOM.hydrate(element, container[, callback])

    与render()相同,但它用于在ReactDOMServer渲染的容器中对HTML的内容进行hydrate操作,react会尝试在已有标记上绑定事件监听器

  • ReactDOM.render(element, container[, callback])

    在提供的container里渲染一个React元素,并返回对该组件的引用(或者对无状态组件返回null),但避免使用该引用(ref替代)

    • 如果React元素之前在container里渲染过,这将会对其执行更新操作,并仅会在必要时改变DOM以映射最新的React元素,会使用React的DOM差分算法进行高效的更新
    • 如果提供了可选的回调函数,该回调将在组件被渲染或更新之后被执行

# 2、打包

# 1、相关包

  • npm-run-all:并行运行
  • nodemon:node服务器,支持热更新

# 2、打包命令

  "scripts": {
    "start": "node ./build/bundle.js", //运行服务器打包代码
    "dev": "npm-run-all --parallel dev:**", //并行运行dev: xx
    "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
    //--watch选项,配置文件修改,则重新打包
    "dev:build:server": "webpack --config webpack.server.js --watch", 
    "dev:build:client": "webpack --config webpack.client.js --watch"
  },
1
2
3
4
5
6
7
8

# 3、打包配置

  • webpack.base.js
  • webpack.client.js
  • webpack.server.js
//1. webpack.base.js: babel-loader

//2. webpack.client.js
entry: './src/client/index.js',
output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
},
loader: style-loader\css-loader\url-loader
module.exports = merge(config, clientConfig);

//3. webpack.server.js
entry: './src/server/index.js',
output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'build')
},
target: 'node', //不必把node内置模块代码进行打包
//webpack-node-externals,这个库会扫描node_modules文件夹中的所有node_modules名称,并构建一个外部函数,告诉Webpack不要捆绑这些模块或其任何子模块
externals: [nodeExternals()],
loader: isomorphic-style-loader/url-loader
//只在对应的DOM元素上生成class类名,然后返回生成的CSS样式代码,将css文件可以转化为style标签插入到html的header头中,实现server render的critical css的插入,提升页面的体验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 2、服务端代码

# 1、相关库

  • react-router-config
    • renderRoutes
    • matchRoutes
  • express-http-proxy
  • react-helmet:head管理工具
const routes = [
  {
    component: Root,
    routes: [
      {
        path: "/",
        exact: true,
        component: Home
      },
      {
        path: "/child/:id",
        component: Child,
        routes: [
          {
            path: "/child/:id/grand-child",
            component: GrandChild
          }
        ]
      }
    ]
  }
];
//1、matchRoutes(routes, pathname)
import { matchRoutes } from "react-router-config";
const branch = matchRoutes(routes, "/child/23");
[
  routes[0],
  routes[0].routes[1]
]
branch[0].match.url;
branch[0].match.isExact;

// 2、renderRoutes(routes, extraProps = {}, switchProps = {})
import { renderRoutes } from "react-router-config";
import routes from '../Routes';
const App = () => {
	return (
        <BrowserRouter>
            <div>
                {renderRoutes(routes)}
            </div>
        </BrowserRouter>
	)
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

# 2、代码分析

  • web服务器放置静态资源
  • api接口代理
  • ssr路由处理:路由匹配、数据获取、ssr
import express from 'express';
const app = express();
var server = app.listen(9002);

// 1. 静态服务器:客户端打包代码index.js放置在public中
app.use(express.static('public'));

// 2. api接口代理
import proxy from 'express-http-proxy';
app.use('/api', proxy('http://127.0.0.1', {
	proxyReqPathResolver: function (req) {
		console.log('111api', req.path, req.url);
		return '/api/' + req.url;
	}
}));

// 3. 路由处理:路由匹配、数据获取、ssr
app.get('*', function (req, res) { /* */}

// 3.1 路由匹配:req.path在路由配置中匹配
import routes from '../Routes';
import { matchRoutes } from 'react-router-config'
const matchedRoutes = matchRoutes(routes, req.path);

// 3.2 数据获取
const store = getStore(req);
// 让matchRoutes里面所有的组件,对应的loadData方法执行一次
const promises = [];
matchedRoutes.forEach(item => {
    if (item.route.loadData) {
        const promise = new Promise((resolve, reject) => {
            item.route.loadData(store).then(resolve).catch((e) => {
                console.log(`1111${item.route.path}error`, e);
            });
        })
        promises.push(promise);
    }
})

// 3.3 数据获取结束后进行ssr渲染
// 使用Promise.all保证所有数据加载完成
Promise.all(promises).then(() => {
    const context = { css: [] };
    const html = render(store, routes, req, context);
    if (context.action === 'REPLACE') {
        res.redirect(301, context.url)
    } else if (context.NOT_FOUND) {
        res.status(404);
        res.send(html);
    } else {
        res.send(html);
    }
}).catch((e) => {
    console.log('1111error', e);
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 1、路由匹配
//react-router-config
//配置对象等价于:<Route>
export default [{
  path: '/',
  //接收参数component:no render or children
  //key:避免重复渲染组件,性能优化
  component: App, 
  loadData: App.loadData,
  routes: [
    { 
      path: '/',
      component: Home,
      exact: true,
      loadData: Home.loadData,
      key: 'home'
    }, { 
      path: '/translation',
      component: Translation,
      loadData: Translation.loadData,
      exact: true,
      key: 'translation'
    },
    {
      component: NotFound
    }
  ]
}];
const matchedRoutes = matchRoutes(routes, req.path);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 2、数据获取
//2.1、getStore
const reducer = combineReducers({
	home: homeReducer,
	header: headerReducer,
	translation: translationReducer
});
export const getStore = (req) => {
	// 改变服务器端store的内容,那么就一定要使用serverAxios
	return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios(req))));
}
//2.2、serverAxios读取文件内容
const createInstance = (req) => {
	return {
		get: (url) => {
			const dataPath = path.join(process.cwd(), 'public', url);
			const data = fs.readFileSync(`${dataPath}`, 'utf8');
			return Promise.resolve({
				data: JSON.parse(data)
			});
		}
	}
};

//2.3、使用promise封装数据获取
matchedRoutes.forEach(item => {
    if (item.route.loadData) {
        const promise = new Promise((resolve, reject) => {
            item.route.loadData(store).then(resolve).catch((e) => {
                console.log(`${item.route.path}error`, e);
            });
        })
        promises.push(promise);
    }
})
//Home组件代码
ExportHome.loadData = (store) => {
	return store.dispatch(getHomeList())
}
//actions.js
export const getHomeList = () => {
	return (dispatch, getState, axiosInstance) => {
		return axiosInstance.get('/api/news.json')
			.then((res) => {
				const list = res.data.data;
				dispatch(changeList(list))
			});
	}
}
const changeList = (list) => ({
	type: CHANGE_LIST,
	list
})
//reducer.js
export default (state = defaultState, action) => {
	switch(action.type) {
		case CHANGE_LIST:
			return {
				...state,
				newsList: action.list
			}
		default:
			return state;
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# 3、渲染

拼接字符串返回:body、数据、样式

  • 使用StaticRouter匹配,使用renderToString渲染字符串
  • 数据挂载在window.context下
  • 样式放置在style标签中
const context = { css: [] };
const html = render(store, routes, req, context);
//util.js中render
export const render = (store, routes, req, context) => {
	const insertCss = (...styles) => {
		styles.forEach(style => context.css.push(style._getCss()))
	};
    //使用StaticRouter路由匹配
	const content = renderToString((
		<Provider store={store} >
			<StaticRouter location={req.path} context={context}>
				<Switch>
					{renderRoutes(routes)}
				</Switch>
			</StaticRouter>
		</Provider>
	));
	const helmet = Helmet.renderStatic();
	const cssStr = context.css.length ? context.css.join('\n') : '';
	return `
			<html>
				<head>
					${helmet.title.toString()}
                    ${helmet.meta.toString()}
					<style>${cssStr}</style>
				</head>
				<body>
					<div id="root">${content}</div>
					<script>
						window.context = {
							state: ${JSON.stringify(store.getState())}
						}
					</script>
					<script src='/index.js'></script>
				</body>
			</html>
	  `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

# 3、客户端代码

  • 获取数据,进行挂载
  • 使用BrowserRouter路由
  • 使用ReactDom.hydrate渲染,复用模版
const store = getClientStore();
const App = () => {
	return (
		<Provider store={store}>
			<BrowserRouter>
				<div>{renderRoutes(routes)}</div>
			</BrowserRouter>
		</Provider>
	)
}
ReactDom.hydrate(<App />, document.getElementById('root'))
1
2
3
4
5
6
7
8
9
10
11

# 1、数据获取

export const getClientStore = () => {
    // 使用window.context上的数据设置初始化
	const defaultState = window.context.state;
	// 改变客户端store的内容,一定要使用clientAxios
	return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}
//request.js中clientAxios
const instance = axios.create({
	baseURL: '/',
	params: {
		secret: config.secret
	}
});
1
2
3
4
5
6
7
8
9
10
11
12
13

# 2、组件交互

  • 初始数据通过服务端获取直接加载
  • 后续交互数据通过客户端发起请求获取
/* 代码结构
-Home
    -store
        -action.js
        -constants.js
        -index.js
        -reducer.js    
    -index.js
    -style.css
*/
class Home extends Component {
	onClickItem(title) {
		console.log('111home', title);
	}
    //该生命周期不会在服务端执行,服务端加载数据使用loadData
    componentDidMount() {
        //服务端有值,客户端直接使用不会重新获取数据
		if (!this.props.list.length) {
			this.props.getHomeList();
		}
	}
	getList() {
		const { list } = this.props;
		return list.map(item => <div className={styles.item} key={item.id} onClick={() => { this.onClickItem(item.title) }} >{item.title}</div>)
	}
	render() {
		return (
			<Fragment>
				<Helmet>
					<title>首页新闻</title>
					<meta name="description" content="新闻页面 - 丰富多彩的资讯" />
				</Helmet>
				<div className={styles.container}>
					{this.getList()}
				</div>
			</Fragment>
		)
	}
}
const mapStateToProps = state => ({
	list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
	getHomeList() {
		dispatch(getHomeList());
	}
});
const ExportHome = connect(mapStateToProps, mapDispatchToProps)(withStyle(Home, styles));
ExportHome.loadData = (store) => {
	return store.dispatch(getHomeList())
}
export default ExportHome;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Last Updated: 12/29/2021, 6:25:06 PM