开源 | Umajs框架react-ssr同构最佳实践方案
导读
Server Slide Rendering,缩写为 ssr 即服务器端渲染,在这儿我们不讨论基于传统模板引擎实现的服务端渲染方案;目前业内前端流行的库和框架主要是React和Vue,MVVM类的开发模式和组件化开发思想提高了前端的生产力,使得构建一个web页面变得越来越容易;但也面临了新的问题,主要是首屏加载白屏,SEO两种场景。在业内的解决方案有Nextjs和Nuxtjs分布针对React和Vue技术栈提供了SSR的解决方案,而这两者虽然解决和降低了实现SSR的门槛,但要结合企业级node开发框架使用时,同构方案的集成和使用并不友好。这也是促使我们基于Umajs进行前后端同构方案探索。
为什么要采用同构
得益于node技术栈的繁荣和发展和SSR技术的出现,随着node越来越被更多的项目使用。我们发现nodejs不仅仅在C端作为SSR的渲染工具去满足SEO场景,在ToB的后台系统中我们也开始大量使用node作为接口网关以及做一些数据聚合的工作,通过node直接和后端服务接口,数据库进行交互。在这背景下,前端不仅仅需要维护前端工程,还需要维护一个node工程。这逐渐成为前端团队面临的新的痛点;所以,无论是SEO场景支持还是后台系统采用同构方案都能为团队带来效率的提示和减少工程的维护成本。本地开发时我们不需要在两个工程之间进行代理就能完整启动前后端所依赖的环境。在构建部署时也不需要分别部署项前端工程和后端工程。
SEO
减少工程维护成本
提高开发,部署效率
业内流行SSR解决方案介绍
-
NextJs / NuxtJs 特点:专门针对SSR场景高度封装,功能强大;基于文件系统为路由的代表; -
egg-react-ssr / Easy-team 特点:专注于egg生态,支持 Egg 所有特性,比如插件机制,多进程机制 -
ykfe/ssr(egg-react-ssr升级版) 特点:相比较Nextjs属于轻量级封装。路由采用服务端路由+基于文件系统路由(前后端路由) 支持 Serverless 一键部署到阿里/腾讯云
现有方案存在的问题
页面组件开发无法保持原始的框架开发体验
比如路由配置,以及运行期SEO动态改写html标题和关键字,引入第三方SDK,页面跳转等。
不统一,难以满足多环境部署
当项目需要部署测试环境,沙箱环境,线上环境,前端需要额外进行环境区分适配各个环境。而且对于服务端渲染模式时,开发者要额外在代码中进行isSSR or isCSR这样的变量区分,以满足不同渲染场景下的数据获取方式。
页面路由不够灵活;
基于文件页面组件目录路由的解决方案,默认页面组件和路由建立一套一一对应的映射规则。比如:pages/list/index.tsx 自动会映射成/list。而当需要对页面组件进行一个AB测实验时,就难以去灵活控制,框架势必需要通过额外的配置文件去完成一套新的路由映射规则。这对开发者来说又增加了学习成本。而且默认按照页面组件命名映射路由这种方式在前后端同构的项目中也存在路由维护的成本,开发者也需要注意区分页面组件路由和接口路由的命明冲突问题。
服务端渲染数据预获取形式受限
目前已有方案大多采用静态方法获取服务端渲染初始化数据,具体实现方式在页面组件中暴露静态方法getInitalProps,在方法提中接受服务端请求上下文,然后通过调用支持浏览器和node环境请求http的工具请求接口返回数据。但这种方式有两个比较严重的问题;一是服务端和客户端请求方式不统一,开发者需要针对两种情况进行编写数据获取逻辑。二是难以进行服务端断点调试,三是对于非http请求获取数据方式不友好,目前也有的框架在支持非http方式时,会将服务端获取数据的函数挂载到请求上下文中。但这种方式时就会回到第一个问题,客户端渲染时则需要将服务端函数包装成一个http的接口去替代兼容。
而在58内部,自研企业级node web开发框架Umajs作为前端node项目基建框架,我们有大量内部技术和业务项目使用自研node框架搭建中间层和构建web页面,现有前后端同构+SSR的解决方案和Umajs框架都有着水土不服的情况,所以我们自研了Umajs-react-ssr解决方案来完美搭配Umajs。
Umajs-react-ssr介绍
以React SSR为例,react-dom/server提供了renderToString以及renderToNodeStream方法可以将我们编写的页面组件在node中解析成html字符串或者stream。然后将返回的字符串注入挂载到客户端渲染时设置的root元素输出到浏览器,在客户端加载js,和css文件然后调用hydrate方法。这是所有SSR解决方案统一的流程和原理,也包括本方案,感兴趣的自行搜索下,业内已经有很多相关的原理介绍。
本文主要通过以下几个方面的使用来介绍umajs-react-ssr的特性。
页面组件目录和规范
路由规则
服务端数据获取
状态管理
自定义html和SEO
css- module
应用运行配置
特性介绍
页面组件目录和规范
页面目录结构
\- src (umajs)
\- web (前端)
\- pages
\- list (页面组件)
\- index.tsx (页面入口)
\- index.scss
\- component (公共组件)
\- ...
不默认路由
方案 | 目录 | 前后端页面路由 |
---|---|---|
nextjs | pages/${name}.ts | /xxx |
egg-react-ssr | pages/${name}/index.ts | /xxx |
Umajs-react-ssr | pages/${name}/index.ts | 不默认路由 |
相比较其他方案我们最大的不同是不默认提供已文件目录自动映射路由,支持个性化配置路由;在umajs-react-ssr中页面组件可以被当成模板标识被任何注册过的后端router或者koa中间件调用;其中${name}即为页面标识。
原生的React页面组件开发体验,支持js和tsx两种模式编写
import React from 'react';
type typeProps = {
ListData :[string]
}
export default function (props:typeProps){
const ListData = ['itme1','itme2','itme3','itme4'];
return (
<div className="list" style={{textAlign: 'center'}}>
<h3>列表</h3>
<ul>
{ListData.map((item,value)=>{
return (
<li key={value}>
<div className="item">{item}</div>
</li>
)
})}
</ul>
</div>
)
}
插件内置了相关文件babel-loader,直接支持js,jsx,ts,tsx等格式编写的页面组件;推荐大家使用tsx构建React页面组件。
路由规则
页面路由
传统SSR解决方案在编写page页面组件时,就已经决定了服务端访问页面路由时的url,比如:pages/list/index.ts会被自动映射到路由localhost:port/list。在umajs-react-ssr中我们默认关闭了基于文件系统的路由,一切交给node框架来决定,前端只关心页面视图渲染。这样可以更灵活的控制路由将要渲染具体哪一个页面组件,同时我们可以给一个页面组件映射多个路由,这在做AB测以及对页面进行大改版时会特别有帮助。
import { BaseController,Path , Query} from '@umajs/core';
import { Result } from '@umajs/plugin-react-ssr'
export default class Index extends BaseController {
@Path("/","/list")
index() {
return Result.reactView('list');
}
@Path("/","/ABlist")
index(@Query('abtest') abtest:string) {
let viewName = 'list'+abtest;
return Result.reactView(viewName);
}
}
动态路由
在nextjs 和ykfe/ssr提供解决方案中,动态路由根据页面组件入口文件的名称进行区分;比如:pages/detail/[id].ts或者pages/detail/render$id.ts。再或者类似egg-react-ssr中提供config.ssr.js配置文件去配置我们的路由和映射规则。而在umajs-react-ssr中我们使用更简单,因为我们的页面组件目录和路由没有关联,所以也不需要改变我们的文件命名,以及去熟悉又一个新的动态路由的规则。
import { BaseController,Path ,Param} from '@umajs/core';
import { Result } from '@umajs/plugin-react-ssr'
export default class Index extends BaseController {
@Path("/detail:id")
index(@Param('id') id:string) {
const detailData = this.detailService.get(id);
return Result.reactView('detail',detailData);
}
}
嵌套路由
由于我们没有提供默认前端路由,所以我们不会有额外的router.js繁杂的配置,在使用React-router时保持前后端路由一致即可。
//web/browserRouter/index.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
export default class APP extends Component {
render() {
return (
<div className="demo">
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/about" component={About} />
<Route component={Home} />
</Switch>
</div>
);
}
}
服务端Controller路由设置
import { BaseController,Path ,Parms} from '@umajs/core';
import { Result } from '@umajs/plugin-react-ssr';
export default class Index extends BaseController {
@Path("/browserRouter","/browserRouter/:path") //// 服务端路由和react-router路由规则保持一致
browserRouter() {
return Result.reactView('browserRouter');
}
}
数据获取
数据获取是服务端渲染最重要的环节,在CSR模式下,数据通常是在生命周期componentDidMount中发起,然后调用setData触发页面渲染。SSR模式中有两种数据获取方案,静态方法获取以及服务端获取。
静态方法getInitialProps
getInitialProps静态方法是由nextjs提出的概念,是在组件实例中挂载一个static静态方法,当服务渲染时预先调用此方法获取到数据,然后再SSR阶段通过props初始化到页面组件中,从而得到完整的html结果。这种方案也被业内其他框架追随,包括egg-react-ssrs ykfe/ssr easy-team等
function Page(props) {
return <div> {props.name} </div>
}
Page.getInitialProps = async (ctx) => {
return Promise.resolve({
name: 'Egg + React + SSR',
})
}
export default Page
服务端获取
import { BaseController,Path } from '@umajs/core';
import { Result } from '@umajs/plugin-react-ssr'
export default class Index extends BaseController {
@Path("/list")
index() {
const ListData = ['itme1','itme2','itme3','itme4','itme5','itme6','itme7','itme8'] //服务端加工初始化props
return Result.reactView('list',{title:'列表',ListData},{cache:true});
}
}
测试结果:通过a标签跳转到详情页通过服务端渲染后页面目标图片被加载时的耗时为:54.29ms
测试结果:通过link前端路由方式实现页面直接跳转,切换为客户端渲染时目标图片耗时为80.50ms
结论:不支持页面之间link跳转并不影响用户的体验,相反拥有更高的性能。同时服务端初始化数据方式更灵活,我们可以在服务端通过获取各个环境服务器上的配置作为props传递给页面组件从而实现多环境的支持。所以在`umajs-react-ssr`中我们推荐使用服务端获取数据的方式而不是静态方法。为了兼容历史项目迁移,默认也支持静态方法获取方式。
优点:
1、支持从RPC框架,数据库初始化数据,
性能更高。
2、SSR,CSR模式数据方案统一
缺点:
1、页面组件之间只能使用a标签跳转
为了验证页面之间跳转时每次都进行a标签跳转进行服务端渲染和页面直接link前端路由跳转时的性能差异,我们做了如下对比。
状态管理
Umajs-react-ssr不封装任何状态管理解决方案,原理上不限制使用redux,mobx等状态管理方案。按照页面去开发组件和路由,这种模式下你可能没有机会用上状态管理。对于通过服务端注入或者静态方法getInitalProps返回的数据,在页面组件中都可以通过Props获取到初始数据,对于简单的页面可以通过Props完成视图的首次渲染,无需在页面生命周期或effect中发起异步请求获取数据。而对于跨组件之间需要共享数据的页面需要使用到状态管理,我们有以下两点建议。
1、对部分简单组件间进行共享数据的业务场景时为了避免层层props传递时,推荐使用 React.useContext结合useReducer实现组件间的数据共享。
2、对大型中后台项目,数据更新逻辑比较复杂,异步处理更新数据等场景时建议使用Redux等社区状态管理方案。
下面以结合useContext和useReducer结合的示例给大家作为参考。
第一步:自定义hooks返回state和dispatch
import { useReducer } from 'react';
import { ActionType } from '../interface';
export default (props:any) => {
function reducer(state: any, action: ActionType) {
switch (action.type) {
case 'updateContext':
return { ...state, ...action.payload };
default:
throw new Error(`Action type ${action.type} is incorrect`);
}
}
return useReducer(reducer, { …props });
};
第二步:创建全局Context
import React from 'react';
import { MixStateAndDispatch } from './interface';
const Context = React.createContext<MixStateAndDispatch<any>>({ state: {} });
export default Context;
第三步:页面入口Provider包裹
import React from 'react';
import Recommend from '../../components/recommend';
import Search from '../../components/search';
import Context from '../../context';
import useContextHooks from '../../hooks/useContextHooks';
export default (props: Ddata) => {
//自定义hooks初始化state和提供dispatch
const [state, dispatch] = useContextHooks(props);
return (
<Context.Provider value = {{ state, dispatch }}>
<div>
<Search></Search>
<Recommend/>
</div>
</Context.Provider>
);
};
第四步:组件内共享全局state和dispatch
import React, { useContext } from 'react';
import Context from '../../context';
function Recommend() {
const { state, dispatch } = useContext(Context);
return (
<div>
{state.RecomemendList.map((recomd)=>{
return (
<List key={recomd.id} {...recomd}/>
)
})}
</div >
);
}
export default Recommend;
示例中使用了useContext+useReducer只是demo,真实项目中还需要结合useCallback,useMemo一起使用,否则会存在性能和reducer多次触发等问题。
而对于useReducer能不能替代Redux的讨论我们就不展开了,大家在选择方案时还需结合自身业务选择合适的才是最好的。
更多状态管理样例参考 uma-useContext-useReducer , uma-react-redux。
自定义html和SEO
NextJs以及其他解决方案采用的是ALL in js思想,将HTML head 里面 meta 信息也作为 React 服务端渲染的一部分,这很符合一切皆为组件的概念;但在使用起来却有一定的上手难度和学习成本,我们需要属性各种Layout的用法配置。
无论是egg还是Nextjs的高度包装实现对开发者来说都有一定的上手成本,对于大部分开发者来讲我们最熟悉的还是html,ALL in JS的使用还是应该有限度的,页面中的head,viewport,title甚至引入第三方SDK场景在React组件中实现且进行服务端渲染适配都会增加框架的复杂和更多规则的学习。React,或者Vue也不推荐这样做。所以我们保留和采用了传统html模板,通过htmlwebpackplugin结合nunjucks引擎来实现。在Pages/xxx/index.tsx同目录下,开发者可以定义index.html文件。使用方法完全遵循htmlwebpackplugin的使用,同时我们可以在html中使用nunjucks的语法解析从controller中初始化到页面组件的数据。
html模板
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=0,width=device-width">
<meta name="format-detection" content="telephone=no">
<meta name="format-detection" content="email=no">
<meta name="format-detection" content="address=no;">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="keywords" content="{{keywords}}">
<title>{{title}}</title>
<!-- 引入第三方外链样式文件 -->
</head>
<body>
<div id="tempate">{{msg}}</div>
<div id="app"></div>
<!-- 引入第三方SDK -->
</body>
</html>
服务端数据注入
// 服务端路由初始化页面数据时传递的数据都可以在html中通过nunjucks模板引擎进行渲染
export default class Index extends BaseController {
@Path("/list")
index() {
const ListData = ['itme1','itme2','itme3','itme4','itme5','itme6','itme7','itme8']
return Result.reactView('list',{title:'列表',keywords:'ssr,umajs-react-ssr',ListData},{cache:true,useEngine:true});
}
}
为了提高性能,在运行时选择开启useEngine属性。相比较业内ALL In js方案,因为我们提前构建编译好了html,从而减少了运行期通过配置生成html的步骤,所以我们的性能更高。相同业务代码下,页面ssr性能是ykfe/ssr的一倍+。
CSS-module
框架没有提供更多的配置去开启和关闭启用css-module ,而是按照样式文件名称进行开启和关闭css modules,样式文件规则为:xxx.modules.(less|scss|css)。通过这种方式我们可以更好的处理第三方组件库不支持css-module的场景;对老项目升级也更加友好。
import React from 'react';
import style from './index.module.css';
type typeProps = {
des: string;
};
export default function (props: typeProps) {
return (
<div className={style.home}>
<p className={style.title}>css modules</p>
<p>Srejs按照样式文件名称进行开启和关闭css modules,样式文件规则为:xxx.modules.(less|scss|css)。</p>
<div className={style.des}>{props.des}</div>
<a href="https://github.com/dazjean/Srejs/blob/mian/doc/cssModules.md">更多css-modules使用请查看文档</a>
</div>
);
}
应用运行配置
无论是nextjs还是业内其他号称最简单,最开箱即用的服务端渲染框架,都没有真的做到开箱即用;开发者还要学习众多各种规范的配置项,包括webpack配置,运行时配置,路由配置等等。
// src/config/plugin.config.ts
export default <{ [key: string]: TPluginConfig }>{
'react-ssr': {
enable:true,
options:{
rootDir:'web',// Pages目录根路径
rootNode: 'app' // 页面根元素挂载ID
ssr: true, // 全局开启服务端渲染
cache: false, // 全局使用服务端渲染缓存 开发环境设置true无效
prefixCDN: '/' // CDN地址前缀
}
}
};
- 关闭开启缓存
export default class Index extends BaseController {
@Path("/list")
index() {
const ListData = ['itme1','itme2','itme3','itme4','itme5','itme6','itme7','itme8']
return Result.reactView('list',{title:'列表',ListData},{cache:true}); //服务端数据是固定不变的 开启缓存,优化首屏加载时间
}
}
- csr和ssr模式动态切换
export default class Index extends BaseController {
@Path("/list")
index() {
const ListData = ['itme1','itme2','itme3','itme4','itme5','itme6','itme7','itme8']
return Result.reactView('list',{title:'列表',ListData},{ssr:false}); //流量高峰时可通过AB测或者其他方案动态调整服务端渲染和客户端渲染流量占比
}
}
生产部署
在部署生产环境之前,客户端代码需要提前编译。否则线上首次访问时会耗时比较长,影响用户体验。编译脚本命令为 npx srejs build
;当我们在服务器或者CDN上只需要更新某一个页面时,我们可以选择性的只构建更新某一个页面的内容。构建命令为npx srejs build ${pageName}
。在58内部,除了基于ione构建进行云容器部署之外,还推荐使用sff(Serverless For Frontend)一站式node部署平台部署umajs-react-ssr应用。
在线案例对比
为了更好的和优秀的框架进行对比,我们直接采用了midway-react-ssr提供的example在umajs-react-ssr中进行了适配以方便进行对比。
-
Umajs-react-ssr -
在线案例: https://umajs-example.58.com/ -
源码:https://github.com/dazjean/Srejs/tree/mian/example/uma-useContext-useReducer -
ykfe/ssr 数据来自在线样例在相同网络环境下多次对比各自的平均值 通过和其他方案对比,在相同的demo实现上,umajs-react-ssr在代码逻辑,开发体验,灵活性以及性能上都拥有更出色的表现。大家也可以通过在线案例进行对比验证。 -
在线案例:http://ssr-fc.com/ -
源码:https://github.com/ykfe/ssr/tree/dev/example/midway-react-ssr
总结
相比较业内实现umajs-react-ssr解决方案对开发者最友好,对页面组件开发和视图没有额外的限制,我们也更加灵活控制路由,将路由控制权交给后端来控制,使得前端部分更专注视图的渲染。通过和其他方案的对比我们也总结umajs-react-ssr具有以下的优点,也为开发者提供选择和参考。
-
不默认路由,不需区分前端路由和后端路由概念,且支持页面级组件AB测;灵活 -
页面组件中没有__isBrowser__之类变量对ssr和csr模式进行特殊区分处理;统一。 -
自定义HTML采用htmlWebpackPlugin,没有runtime,页面响应速度更高;高性能。 -
支持html中使用nunjucks类模板引擎语法实现SEO;易上手。 -
不必改变react书写习惯,保持原生的React开发体验;友好,易升级。 -
数据获取由服务端统一处理加工,页面视图开发和数据加工分开处理;逻辑更清晰。 -
支持SSR和CSR动态调整,支持SSR缓存,降级。高可用。 -
支持其他koa开发框架使用。可扩展。 -
支持MPA,各页面组件可单独构建,可页面级更新。
如何使用
Uma cli脚手架通过初始化工程命令可快速初始化react-ssr配置工程 .
npm i @umajs/cli -g // 安装cli工具
uma project umajs-react-demo //通过uma初始化工程,选择react模板工程
后续规划
目前umajs-react-ssr已经在业务中落地和使用,后续功能也在持续优化和迭代。
-
Umajs-vue-ssr适配 -
自定义webpack配置规则优化 -
开发环境打包优化 ,vite支持 -
服务端渲染动态部署支持,web页面组件更改无需node重启
资源链接
Umajs 是一个简单易用、扩展灵活,基于 TypeScript 的 Node.js Web 框架。从 2018 年立项至今,Umajs 团队持续的对框架打磨、迭代,在生产环境稳定运行近两年后,于 2020 年 8 月份开源
-
https://github.com/Umajs -
https://github.com/Umajs/plugin-react-ssr -
https://github.com/dazjean/Srejs
有奖互动
Perseverance Prevails
欢迎在评论区为作者大佬打call,将抽取点赞前五名送出58帮帮手机支架一个。
开奖时间:6月30日 18:00