什么是可降级的同构框架

这里指的可降级与服务器降级意思相近(主逻辑失败采用备用逻辑的过程),考虑到服务端渲染并发及高负载的问题,在主服务器无法提供正常服务的时候可降级为”低功耗模式”,采用客户端渲染方式来减轻服务器负载。

框架选型

next.js

我看了一下next.js,他们的开发方式很新颖,尤其是路由配置,完全不用自己配,按照他的规则来做就行了。但我这个人比较求稳,这种约定大于配置的方式对于我来说总觉得会有坑,无法自己掌控,找不到解决办法还得看源码才知道。

还是求稳的我,毕竟服务端渲染是需要消耗服务器性能的,尤其是高并发和内存溢出,可能会导致服务器响应变慢甚至挂掉,所以我希望我的框架还能降级,这个降级与服务器降级类似,在服务器高负载的时候,切换另一台机器,启动”低功耗模式”(也就是客户端渲染的单页应用)来继续运作,我查了next.js好像没有这个功能(如果有请在评论告知)。

cra-ssr

实在找不到既能服务端渲染又能客户端渲染的框架,最后我就找了这个项目https://github.com/cereallarceny/cra-ssr 进行改造,改造还是蛮成功的,过程遇到很多坑,在这里分享一些过程步骤。

改造之旅

其实cra-ssr这个项目本身已经做好了服务端同构渲染,在改造之前最好先理解cra-ssr实现同构渲染的流程原理再看下面的步骤,下面是cra-ssr实现同构渲染的流程图

同构渲染流程图
同构渲染流程图

下图是降级改造后的流程图:

服务端同构降级改造流程图
服务端同构降级改造流程图

上图里黄色区域的frontload是用在服务端渲染时预先处理的接口请求预取数据,然后放入准备好的global_state,global_state是一个store,作用是存放预取数据再connect给组件渲染,同时也可以作为服务端与客户端都可用的全局变量;

蓝色节点是服务器环境,橙色节点是客户端环境,当服务器渲染响应给客户端之后,用户进行了路由跳转,这时前端无刷新跳转到新页面,再走黄色区域取到页面数据,交给client_render渲染出新页面

环境区分🤔

同构框架由于是同一套代码,但环境不同有些对象是不自带的,我们要加以区分避免出现问题,这个项目有提供一个isServer变量用来判断是服务端环境还是客户端环境,而我们做降级了就还要加多一个环境判断是否是客户端渲染的模式来区分,后面就知道为什么要做区分了。

我们需要做3个环境的区分
1.服务端渲染环境; 2.服务端渲染完成后的客户端环境;3.客户端渲染环境;
我是这么做环境区分的:

1
2
3
4
5
6
// 通过node环境独有的process和具体的环境变量区分isServer
export const isServer = !!process&&!!process.env&&(process.env.RENDER_ENV == 'server');
// 只要不是服务端渲染环境都属于isClient
export const isClient = !isServer;
// 区分降级后的客户端渲染isCSR,CSR是没有服务端渲染过后填充的数据__PRELOADED_STATE__的
export const isCSR = !isServer && !window.__PRELOADED_STATE__;

首屏接口数据预取🤓

cra-ssr是使用react-frontload来做首屏异步处理的,用法看这个文件src/app/routes/profile/index.js,在服务端通过预取数据交给状态管理redux的store,插入到window.__PRELOADED_STATE__作为初始store,在客户端拿window.__PRELOADED_STATE__作为初始store,connect组件得到数据填充,

而客户端渲染是没有预取数据window.__PRELOADED_STATE__的,所以frontload要同时满足下面3个条件:
①frontload在服务端渲染完之后到客户端首屏不能重复执行发请求
②降级后客户端渲染模式能执行frontload发请求拉取数据并保证跟服务端同样的写法
③服务端渲染完之后到客户端经过用户点击路由跳转又要能像客户端一样请求拉取数据

为了保证同样的写法,服务端渲染与客户端渲染必须统一用状态管理的store,需要封装一个特定的global_state用来传首屏数据的store,再封装一下frontload,写了个Adapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { frontloadConnect } from 'react-frontload';
import {isServer, isCSR} from 'xxx/xxx';
import {change_state, get_state} from 'xxx/global_state';
import {connect} from 'dva';

export const frontload = (frontload_fn) => {
return (Target) => {
// 为组件默认注入global_state
return connect(({global}) => ({global}))(frontloadConnect(async (props) => {
// get_state('client_load') => 在客户端触发路由跳转的时候为true
if (get_state('client_load') || isServer || isCSR) {
await frontload_fn(props)
}
})(Target))
}
}

用这个frontload来代替原来的frontloadConnect函数,不仅简化了写法,还保持了同一套代码满足上面三个条件。

在组件里可以这么写:

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
//(本项目引入了dva、Typescript、scss和react-css-modules)
import * as React from 'react';
import { isCSR } from 'xxx/xxx';
const styles = require('./index.scss');
const CSSModules = require('react-css-modules');
import { frontload } from 'xxx/adapter';
import { API } from 'xxx/http';
import {config} from 'src/utils/config';


@frontload(async (props) => {
const res = await API.get('/getNews');
// 通过更新store的global达到统一服务端与客户端的写法。
props.dispatch({type: 'global/set', payload: {data_list: res.data}})
})
@CSSModules(styles)
export default class News extends React.PureComponent<any,any>{
constructor(props){
super(props);
this.state = {
news_list: [],
}
}
componentDidMount() {
this.setState({
news_list: this.props.global.data_list,
})
}
render() {
const news_list = this.props.global.data_list;
return (
news_list.map((item) => {
return <section>
<p>标题:{item.title}</p>
<p>摘要:{item.abstract}</p>
</section>
})

)
}
}

添加客户端渲染启动服务器😎

还要添加一个启动客户端渲染的服务器文件,比如csr_server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
var path = require('path')
var express = require('express')
var compression = require('compression')
var app = express()
var data = require('./data_config.json');
app.use(compression());
// 构建后的资源文件夹
app.use(express.static(path.join(__dirname, '../dist/')));

port = 8080
app.listen(port, function () {
console.log('The app server is working at ' + port)
})


这就是我改造服务端同构框架降级的一些步骤,具体还是要自己好好理解才行,根据自身项目需求调整(比如我的项目还引入了dva、typescript之类的),会遇到很多坑的😂