开源 | Umajs框架react-ssr同构最佳实践方案

Posted 58技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了开源 | 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场景支持还是后台系统采用同构方案都能为团队带来效率的提示和减少工程的维护成本。本地开发时我们不需要在两个工程之间进行代理就能完整启动前后端所依赖的环境。在构建部署时也不需要分别部署项前端工程和后端工程。

开源 | Umajs框架react-ssr同构最佳实践方案

  • SEO

  • 减少工程维护成本

  • 提高开发,部署效率


业内流行SSR解决方案介绍

目前国内业内比较流行的SSR的解决方案主要是以NextJs / NuxtJs为代表的专门为SSR构建的框架和egg框架生态提供的解决方案为主。他们的特点如下:
  • 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 (公共组件)    \- ...
在umajs-react-ssr中pages目录下存放各个页面组件,比如pages/list/index.ts,这和我们日常开发MPA项目保持一致的规范,list文件夹下的index.ts会作为webpack打包的入口文件进行编译,在开发模式会被编译成提供给客户端和服务端渲染使用的页面级bundle。构建后的产物可通过项目根路径文件夹dist查看。
  • 不默认路由

方案 目录 前后端页面路由
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.jsimport 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
上面样例来自egg-react-ssr,通过这种方式可以将服务端获取数据的逻辑和客户端页面渲染编写在一个文件中。在ssr时静态方法在node端被调用,csr时通过高价组件包装,在link或者API跳转时执行。而这种方式的实现其实在真实场景中并不友好,当前前后端的交互不一定是http,而是rpc框架或者直接从数据库中获取并加工获得。这种方式就存在很大的局限,当需要调用非Http接口时则需要在ctx上注入node环境中的方法,而且这种将node环境和客户端页面组件混写在一起的方式也会增加开发人员的理解,所以我们也支持了在服务端获取数据的方式。
    优点:支持前端路由跳转和a标签
    缺点:
    1、不方便服务端调试
    2、只对http接口友好
    3、服务端客户端请求参数不一致

服务端获取

Umajs-react-ssr页面组件的渲染是在controller路由中进行分发调用,在controller中我们可以直接调用service通过rpc或者数据库中获取到初始化的数据,同时进行加工并在指定渲染页面组件时传递给页面组件。在react中无论是csr还是ssr模式下我们直接通过props可以获取到服务端初始化的数据。这样让页面组件专注于视图的渲染,数据的处理和加工交给node来实现。在客户端也无需发起http请求,效率也会更高。
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}); }}

开源 | Umajs框架react-ssr同构最佳实践方案

测试结果:通过a标签跳转到详情页通过服务端渲染后页面目标图片被加载时的耗时为:54.29ms

开源 | Umajs框架react-ssr同构最佳实践方案

测试结果:通过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的用法配置。

开源 | Umajs框架react-ssr同构最佳实践方案

无论是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配置,运行时配置,路由配置等等。

而umajs-react-ssr的配置则是非常简单的,我们内部集成了webpack的常用配置和内置了bable,js,ts,css,url,scss,less各种常用loader。开发者初始化工程后除了在plugin中引入@umajs/plugin-react-ssr后无需再单独配置众多框架特有配置文件。
 // 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地址前缀  } } };
没错,我们提供的配置项就是如此简单,通过插件配置我们提供了全局开启和关闭服务端渲染和缓存策略。在运行期,通过插件导出的Result.reactView对页面进行ssr时我们还可以进行动态调整。在运行期只支持ssr,cache,useEngine属性传入。
- 关闭开启缓存	
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}); //服务端数据是固定不变的 开启缓存,优化首屏加载时间 }}