# 正文
react ssr 到现在已经不是新技术,技术原理也不复杂,但是真要自己弄出一个完整的轮子并非易事,有非常多的细节和边边角角需要处理。
所以除了自己来造轮子,我们还可以站在巨人的肩膀上,直接使用业内现成的框架进行开发。
造轮子可以,但是不要闭门造车,所以本节我们来了解下业内框架他们是怎样实现的,也侧面的看下我们已有功能的实现是否合理,是为了验证我们的方案,更是学习。
这里主要来看下next.js和egg-react-ssr的实现。
当然还有umi,不过umi ssr代码核心部分也是egg-react-ssr团队贡献的代码,所以就不做对比了。
另外在客户端组件渲染时会使用服务端直出的数据问题,也是参考egg-react-ssr来实现的,只是细节不同。
# 数据预取
看下这两个框架在服务端如何获取组件数据的。
next.js数据预取代码
import React from 'react'
export default class extends React.Component {
static async getInitialProps({ req }) {
const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
return { userAgent }
}
render() {
return (
<div>
Hello World {this.props.userAgent}
</div>
)
}
}
当页面渲染时加载数据,使用了一个异步方法getInitialProps。它能异步获取数据,并绑定在props上。当服务渲染时,getInitialProps将会把数据序列化,就像JSON.stringify。
当第一次进入页面时,getInitialProps只会在服务端执行。只有当路由跳转(Link组件跳转或 API 方法跳转)时,客户端才会执行getInitialProps。
另外此方法只能用于页面组件内,不能在子组件内使用。
egg-react-ssr数据预取代码
import React from 'react'
import { Link } from 'react-router-dom'
import './index.less'
function Page (props) {
return (
<div className='normal'>
<div className='welcome' />
<ul className='list'>
{
props.news && props.news.map(item => (
<li key={item.id}>
<div>文章标题: {item.title}</div>
<div className='toDetail'><Link to={`/news/${item.id}`}>点击查看详情</Link></div>
</li>
))
}
</ul>
</div>
)
}
Page.getInitialProps = async (ctx) => {
// ssr渲染模式只在服务端通过Node获取数据,csr渲染模式只在客户端通过http请求获取数据,getInitialProps方法在整个页面生命周期只会执行一次
return __isBrowser__ ? (await window.fetch('/api/getIndexData')).json() : ctx.service.api.index()
}
export default Page
页面初始化时,服务端根据当前请求的path,来确定我们要渲染哪一个组件,getComponent可以理解为一个根据path从路由表中找到匹配的组件的方法,检测该组件上有没有getInitialProps静态方法,这里之所以要用静态方法,是为了不需要实例化就可以拿到方法。
如果有的话,将调用这个方法,将数据作为组件的props传入,使得组件可以通过props.xxx的方式来读取到服务端获取的数据。
# 本应用的数据预取
import React from 'react';
import {
Link
} from 'react-router-dom';
import './index.scss';
import img from '../../public/img.jpg';
import PageContainer from '../../common/components/page-container';
function Index(props) {
return <div className="page-index-box">
<p>首页</p>
<img src={img} />
</div>
}
Index.getInitialProps= async ()=>{
console.log('fetch data index');
//模拟数据请求方法
//...
return {
page: {
tdk: {
title: '首页 - koa-react-ssr',
keywords: '关键词 - koa-react-ssr',
description: '描述'
}
}
};
}
export default PageContainer(Index);
同样为组件添加getInitialProps静态方法,服务端根据当前请求的path,调用matchRoute方法查找到对应的路由,得到具体的组件,判断组件上是否有getInitialProps此方法,然后进行数据预取。
最后把数据作为组件的props,在组件内可以通过props.initialData固定属性来获取。
整体来看,本应用的实现方式和egg-react-ssr,next.js非常相似,可能这也是业内一种默认的通用做法吧。
# 数据脱水
从运行时的页面看下服务端直出数据的方式。
- next.js
数据直出到页面后,通过script标签来进行包裹,且type="application/json",标签内直接是 json数据。

- egg-react-ssr
也是作为脚本加载,然后将数据保存在了window.__INITIAL_DATA__全局变量内。

- 本应用
为了防止xss攻击,将数据放在了textarea标签内,客户端使用前先进行一次获取

最后,本应用和他们两个框架的结果相同,只是表现形式不同。
# 热更新
都实现了模块热替换的功能。
next.js采用hot-middleware+webpackHotDevClient.js实现egg-react-ssr采用社区成熟库webpack-dev-server实现,使用代理抹平了双服务模式,对外看到的是一个服务- 本应用采用社区成熟库
webpack-dev-server实现,未做代理转发,目前是使用双服务模式
再次确认了下这两个框架是否支持了模块热更新的同时,是否能保存组件的状态,不过都不支持。
# 路由配置
- next.js
该框架是约定式路由,没有路由配置文件,只要在 pages 文件夹下创建的文件,都会默认生成以文件名命名的路由,的确很方便,但是有些过度封装了。

- 本骨架和
egg-react-ssr直接使用react-router,依旧使用传统的spa应用的使用方式,手动编写路由规则,更加方便你去控制你的项目结构。
egg-react-ssr 配置方式
const resolvePath = (path) => require('path').resolve(__dirname, path)
module.exports = {
type: 'ssr', // 指定运行类型可设置为csr切换为客户端渲染
routes: [
{
path: '/',
exact: true,
Component: () => (require('@/page/index').default), // 这里使用一个function包裹为了让它延迟require
controller: 'page',
handler: 'index'
},
{
path: '/news/:id',
exact: true,
Component: () => (require('@/page/news').default),
controller: 'page',
handler: 'index'
},
{
path: '/test',
exact: true,
Component: () => (require('@/page/test').default),
controller: 'page',
handler: 'index'
}
],
baseDir: resolvePath('../'),
injectCss: [
`/static/css/Page.chunk.css`
], // 客户端需要加载的静态样式表
injectScript: [
`<script src='/static/js/runtime~Page.js'></script>`,
`<script src='/static/js/vendor.chunk.js'></script>`,
`<script src='/static/js/Page.chunk.js'></script>`
], // 客户端需要加载的静态资源文件表
serverJs: resolvePath(`../dist/Page.server.js`)
}
本骨架的配置方式
//路由配置文件
import React from 'react';
//组件动态加载容器
import AsyncLoader from './async-loader';
function pageNotFound() {
return <div>404页面</div>
}
export default [
{
path: ['/','/index'],
component: AsyncLoader(() => import('../pages/index')),
exact:true
},
{
path: '/list',
component: AsyncLoader(() => import('../pages/list')),
exact: true
},
{
path: '/about',
component: AsyncLoader(() => import('../pages/about')),
exact: true
},
{
path: '*',
component: pageNotFound,
exact: true
}
]
这两者都属于集中式路由配置,更加直观和更灵活的进行配置。多人开发的时候可能维护上有点小瑕疵,容易有冲突。
# 路由分割
# next.js
自动根据页面进行代码分割,无需配置。
# egg-react-ssr 实现方式
使用react-loadable库实现,实现方式和官方的方式不同。
没有将服务端bundle打包成多个文件,依然保持一个文件,因为服务端直接处理的是静态路由。
可以参考以下配置
{
path: '/news/:id',
exact: true,
Component: () => (__isBrowser__ ? require('react-loadable')({
loader: () => import(/* webpackChunkName: "news" */ '@/page/news'),
loading: function Loading () {
return React.createElement('div')
}
}) : require('@/page/news').default // 通过这种方式来让服务端bundle不要分块打包
),
controller: 'page',
handler: 'index'
}
这样配置有个坑,导致Loadable没办法预先知道你有哪些组件被包裹了,所以没办法直接调用Loadable.preloadReady()来预加载。
只能自己写一个preloadComponen方法来手动调用组件的preload方法了。
import { pathToRegexp } from 'path-to-regexp'
import cloneDeepWith from 'lodash.clonedeepwith'
import { RouteItem } from './interface/route'
const preloadComponent = async (Routes: RouteItem[]) => {
const _Routes = cloneDeepWith(Routes)
for (let i in _Routes) {
const { Component, path } = _Routes[i]
let activeComponent = Component()
if (activeComponent.preload && pathToRegexp(path).test(location.pathname)) {
// 只有在你访问的path和组件为同一个path才拿到真实的组件,其他情况还是返回Loadable Compoennt来让首屏不要去加载这些组件
activeComponent = (await activeComponent.preload()).default
}
_Routes[i].Component = () => activeComponent
}
return _Routes
}
export {
preloadComponent
}
然后在客户端渲染的时候调用一下该方法
const clientRender = async () => {
//预加载
const clientRoutes = await preloadComponent(Routes)
// 客户端渲染||hydrate
ReactDOM[window.__USE_SSR__ ? 'hydrate' : 'render'](
<BrowserRouter>
{
// 使用高阶组件getWrappedComponent使得csr首次进入页面以及csr/ssr切换路由时调用getInitialProps
clientRoutes.map(({ path, exact, Component }) => {
const activeComponent = Component()
const WrappedComponent = getWrappedComponent(activeComponent)
const Layout = WrappedComponent.Layout || defaultLayout
return <Route exact={exact} key={path} path={path} render={() => <Layout><WrappedComponent /></Layout>} />
})
}
</BrowserRouter>
, document.getElementById('app'))
if (process.env.NODE_ENV === 'development' && module.hot) {
module.hot.accept()
}
}
# 本骨架实现方式
没有使用react-loadable,而是依据动态导入原理,包装了一个自定义异步组件加载器AsyncBundle,基本原理和react-loadable都是一样的。
- 通过高阶函数对返回一个函数组件,同时为函数添加异步属性,后面服务端和客户端预加载直接通过此属性进行判断
- 服务端代码会被打包成多个文件
- 服务端在请求前对组件进行预加载,也就是转换为静态组件
- 客户端代码会打包成多个文件
- 客户端
bundle预加载后再渲染页面
容器组件
import React from 'react';
import LoadingCompoent from './loading-compoent';
/**
* 动态加载组件组的容器组件
*
* @class Bundle
* @extends {Component}
*/
export default class AsyncBundle extends React.Component {
constructor(props) {
super(props);
this.state = {
mod: null
};
}
componentDidMount() {
if (!this.state.mod) {
this.load(this.props);
}
}
load(props) {
this.setState({
mod: null
});
//注意这里,使用Promise对象; mod.default导出默认
props.load().then((mod) => {
this.setState({
mod: mod.default ? mod.default : mod
});
});
}
render() {
return this.state.mod ? this.props.children(this.state.mod) : <LoadingCompoent/>;
}
}
高阶函数,返回异步加载组件的包装组件
import AsyncBundle from './async-bundle';
import proConfig from '../../share/pro-config';
import React from 'react';
function AsyncLoader (loader) {
function asyncFn(props) {
return <AsyncBundle load={loader}>
{(Comp) => <Comp {...props} />}
</AsyncBundle>
}
//标记为异步组件,双端会根据此属性进行预加载
asyncFn[proConfig.asyncComponentKey] = true;
return asyncFn;
}
export default AsyncLoader;
参考一个路由配置
AsyncLoader函数内会标记此组件为异步组件
{
path: '/list',
component: AsyncLoader(() => import('../pages/list')),
exact: true
}
服务端组件预加载
//将路由转换为静态路由,进行组件预加载
async function getStaticRoutes(routes) {
const key ='__dynamics_route_to_static';
if (global[key]){
console.log('cache route');
return global[key];
}
let len = routes.length,
i = 0;
const staticRoutes = [];
for (; i < len; i++) {
let item = routes[i];
if (checkIsAsyncRoute(item.component)) {
staticRoutes.push({
...item,
...{
component: (await item.component().props.load()).default
}
});
} else {
staticRoutes.push({
...item
});
}
}
global[key]=staticRoutes;
return staticRoutes; //返回静态路由
}
客户端渲染
function clientRender(routeList) {
let initialData = JSON.parse(document.getElementById('ssrTextInitData').value);
window.__INITIAL_DATA__ = initialData;
//查找路由
let matchResult = matchRoute(document.location.pathname, routeList);
let { targetRoute } = matchResult;
if (targetRoute) {
//预加载完成后进行 render
if (targetRoute.component[proConfig.asyncComponentKey]) {
targetRoute.component().props.load().then(res => {
//异步组件加载完成后再渲染页面
console.log('异步组件加载完成.....');
renderDom(routeList,initialData);
});
}
} else {
renderDom(routeList);
}
}
# CSS 资源
主要看下 css 资源是如何处理的。
- next.js
该框架采用的是将css代码最终打包到一个文件内,作为资源进行加载。

- egg-react-ssr
也是最终将css代码提取到一个文件内。

- 本骨架实现方式
我们目前有两种方式来处理css,一种是将代码进行提取到一个文件内作为资源进行加载。
另外一种是同构处理,页面初始化时服务端会搜集组件所需的 css,然后作为内联形式输出。
客户端渲染时会判断当前页面内是否已存在,只有不存在的情况下才会动态的插入样式。

其实css同构处理的配置比较繁琐,直接提取为一个css文件也不失为一种便捷的处理方式。
# csr/ssr 双模式
next.js是一个纯粹的ssr应用服务框架- 本应用和
egg-react-ssr即支持ssr也支持csr,且支持本地开发与生产环境ssr/csr两种渲染模式无缝切换
# 总结
本骨架的方案和egg-react-ssr比较相似,不过仍然有很多区别,还有很多地方可以借鉴和学习,但整体方向比较相似。
next.js是很成熟的React SSR应用开发框架,进行了大量的封装,很多东西都是黑盒的,只能按照他已有的模式进行开发,很难进行改造,且只支持ssr一种渲染模式。
比较方便的一个是它的约定式路由,根据你的目录和文件来处理的,不需要对路由进行维护,但是需要按照他的规则来创建文件,让你可以有更多时间来关注业务,而无需关注底层和配置。
我们的骨架是完全透明的,所有的配置和代码都在项目里,可以很方便的进行改造,路由仍然是传统的集中配置的路由,符合我们以往的开发习惯,但是多人开发可能有些问题,会产生冲突。不过也可以进行优化一下,在每个页面内增加一个路由配置,分开维护,最后通过一些手段将各个页面的路由合并为一个再使用。
另外本骨架同时支持csr和ssr两种渲染模式无缝切换。
生产环境来说,大家都采用的是将所有的css打包合并到为一个文件方式,如果项目过大的话 css文件可能会过大,导致页面渲染变慢。
所以本骨架使用同构对 css进行按需加载,消除了独立css文件,css代码在服务端渲染时会和html内容一起直出,客户端渲染时会动态的创建style标签插入到head内。
这样的方式可以让客户端加载更少的代码,不好的地方就是css代码会打包进js,修改css代码也会导致相关的js模块缓存失效,另外同构配置比较繁琐,坑较多,另外对代码侵入性较大,这也可能是其他框架不具备此能力的原因吧。