服务器烘托(Server-Side Rendering)并不是一个杂乱的技术,而 服务器烘托服务器同构烘托 则是2个不同的概念,重点在于:同构,要做到一套代码完美的运转在浏览器与服务器之上不是一件简略的事情,现在业界I v R G Q 3 p也没有特别满意的计划,都y 2 7需求或多或少的对不同的环境做差异化处理。

同构烘托的方针与含义

一般同构烘托首要是为了:

  • 利于 SEO 搜索引擎收录
  • 加速首屏出现时间
  • p + a A E 5 , ~时拥有单页(SPA)多页路由的用n d E d i户体会

一般同构烘托需求做到:

  • 浏览器与服务器复用同一套代码。
  • 用户拜访的第一个页T O O 9 S M l面(首屏)由服务器烘托输出,以利于 SEO 和加速出现速度。
  • h Y 5 % O Z 0 o 8屏由服务器烘托输* K 3 O 0 ^出之后,浏览器在其基础上E T H L Y J进一步烘托,但不再做重复作业,包含不再重复请求数据。
  • 之后用户拜访的其l Y a ! f y它页面都不再通过服务器烘托,以减少服务器压力和达到单页(x Q F = B Y c L CSPA)的用户体1 o G 8 j B / O会。
  • 在之后的交互过程3 U 6 ~中刷新浏览器,需求保持I S 5 X + j当时页面并从头由服务器烘托,以实现多页路由的用户体会。

同构烘托的难点与金钥匙

获取* / = * M z N O 1初始化数据

同构烘托的首要难点在于 Client 端烘托时组件生命P 6 }周期钩子承载了太多的职能, * 8与副作用,比方:获取数据、路由、按需加载、模X 7 / ]块化等等,这些逻辑被涣散在各个组件中随着组件的烘托动态履行,而它们的履行又再次引起组件的从头烘托。简略来说便是:

Render -> Hooks -> Effects -> ReRender -> Hook A u 8 U S w qks -> Effects…

h b d样的烘托流程在 Servg # ( B Xer 端是不可的,因为一般 Sever 端不会 ReRender,因而有必要把一切副作, U ` K u Z =用都提早履行,然后在一次性 R. e Uender,简略来说便是:

Effects -> State -> Render

: . | u d么解决计划便是P z u ) U将这些副作用尽量的与组件的生命周期钩子脱离,并引进独立的状况管W h [ r } _ 7理机制来管理它们,让 UI 烘托变成简略纯粹的 PrueRender,而这正是@medux 所倡导的状况驱动理念。

异步按需加载

在 Client 端烘托时,为了提升加载速度咱们一般对代码进行 chunk 分包、并运用异步按需加载来优化用户体会。而在 Server 端烘托时这变得完全没必要,反而会拖f q Q % # ; p 4慢加载速度。怎么在 server 端中替换异步代码为同步代码呢?正好@medux将: j v模块加载视为一种装备战略,它K o n w K d能够很轻松的让将模块加载在同步和异步之间切换。

运转 Demo

本项目 fork 自medux-reacy ~ 9 / Z Zt-admin,这是一个运用 Medux+React+Antd4+Hooks+Typescript 开发的 WEB 单页运用,你能够从本项目中看到怎么将一个 SinglePage(单页运用) 快速转换为支撑 SEO 的多页运用) B

项目地址:medux-react-ssr

  • 在线预览

打开以下页面,运用鼠标右键点击“检查网页源码”,看是否输出了 Html

  • /login
  • /register
  • /article/home
  • /article/service
  • /article/about

装置

// 注意一下4 + o,因为本项目风格检查要求以 LF 为换行符
// 所以请先关闭 Git 装备中 autocrlf
git config --global core.autocrlf false
git clone https://github.com/wooline/medux-reu 0 j nact-ssr.git
cd medux-react-ssr
yarn install

以开发形式运转

  • 运转 yarn start,会自动发$ q R动一个开发服务器。
  • 开发形式时 Reactl 9 K 8 y 热更新运用最新的 React Fast Refd d : ^ S aresh 计划,需求装置最新的 React Developer Tools。

以产品形式运转

  • 首先运转 yarn build-local,会将代码编译到 /dist/local 目录
  • 然后进入 /dist/local 目录下,运转 node start.js,会发动一个产品服务器 Demo,但是真正线上运转主张J b I . j ^ O y ;运用 Nginx,输出目录中有 Nginx 装备样例可供参考

首要改造步骤阐明

确认方针与任l T V H R X b

这是一个典型的后台管理系统,页面首要分为 2 类:

  • 第一类是需求用户登录后可见的如:/admin/xxx
  • 第二类则J 1 L是无需登录的如:/article/xxx

咱们之所以要运用 SSR 改造它首要是为了让第二类页面能被搜索引起收录(SEO),而关于第一类页面,因为需求用户? @ m (登录,所以关于搜索引擎也没什么含义,咱们依然沿袭纯浏览器烘托就好。

两个进口,一套代码,两套输出

使用Medux改造单页应用(SPA)为服务器同构渲染(SSR)

区分发动进口

既然是同构m ; z 6 w + ; ~咱们当然/ | o G不期望为 2 端渠道做太多的差异化处理,但是仍是会有少量的定制代码。比方发动进口,原来是./ U G {/src/index.ts,现在咱们需求将其区分为:

  • client.ts 原浏览器端进口文件,运用 buildApp()方法创建运. [ / Y b– – 2 +
  • seh G H 3 % Zrver.ts 新增@ S ~ | b服务器端进口文件,运用 buildSSR()方法创建运用

运用这 2 个不同的进口,咱们集中构建一些 shim,抹平一些渠道的Q @ * t } ~ = r差异, * Y t化。

区分 webpack 编译装备

运转% – T在 Sever 端的代码无需异步按需加载、无需处理 CSS、无需处理图片等等,所以咱们运用 2 套 webpack 装备来进行编译打包并分别输出在 dist/client/dist; k J u/server/ 目录下。

关于 Sever 端的输出其实就只有一个 main.js 文件。

编译与运转

怎么布置和运转编译后输出的代码?本项目编写了一个 express 的简略样例可供参考,目录结构大致为这样:

dist
├── package.json // 运转需求的依靠
├── start.| = L ( $ ? gjs     // nodejs发动进口
├── pm2.json     // pm2布置装备
├── nginx.conf   //= X 7 - _ N S v e nginx装备7 ( @ : ~ , 9样例
├── env.json     // 运转( i y q 7环境变量装备
├── 404.html     // 404过错页面
├── 50x.html     // 500过错页面
├── index.html   // SSR模版
├── mock         // mock假数据目录
├── html         // 生成的纯静态化页面目录
│     ├── logib ; $ K wn.html
│     ├── registeru V w |.html^ . %
│     └── article
├── server        // Server端输出目录
│     └── main.js //& N B w N j ] Q t SSR主程代码
├── client        // Client端输出目录
│     ├── css     // 生成的CSS文件目录
│     ├── imgs    // 未经工程化处理的图片目录
│     ├── media   // 经webpack处理过1 9 f的图片目录
│     └── js      // 浏览器运转JS目录
  • 关于 dR g ~ y M cist/client 便是一个静态目录,你能够运用 Nginx 布置

  • 关于 dist/server 其实l [ z 7 d % W便是一个 JS Modug G s O w B o Sle 文件 main.js,它只有一个default expe + 4 ) D { { y ;ort的方法:

    expor* & ~ A M v i Ft default functy o [ I c Oion render(
    location: string
    ): Promise<{
    html: string | ReadableStK P o ; A #ream<any>;
    data: any;
    ssrInitStoreKey: string;
    }>;
    

    你能够运用恣意 node 服务器B , J(如 express)来履行它,并得到烘托后的 data、html、已及脱水数据的key。至于你要怎么让服务器输出这些结果,以及怎么处理履行过程出现的异常和过错,你能够自由发挥,例如:

处理初始化数据

前面咱们说过运用在 Server 端运转的流程是:Effects -> St3 k 6 ) X P Cate -> Render,也便是说:先获取数据,再烘托组件

在 medux 结构中数据处理是封装在 model 中的,而初始化数据一般是在 model 中通过监听 module.Init 这个 Action 来履行 Effect,然后处理数据并转化为 moduleState。S 6 / X当一个 modu| 3 j gle 被加p ^ d 8 )载时,不论 Client 端仍是 Server 端都会触发w C l & T _ W 6这个 Action,所以在这个 AN 3 = | O c X q octionHandler 中咱们要u 2 x注意的是:假如 Server 端现已做过的作业,Client 端没必要再重复做了+ : Z ` / _ E T。能够通过moduleState.isHydrate来判断当时的 moduleState 是否现已是服务器处理过,例如:

// src/modules/appn j ! a w q G/model.ts
@effect(null)
protected async ['this.I^ % B ] Lnit']() {
if (this.state.isHydrate) {
//假如现已通过SSR服务器烘托,那么D  p g { G 7 s ggetProjectConfig()无需履行了
const curUser = await api.getCurUser();
this.dispatch(this.actions.putCurUs; X B _ xer(curUser));
if (curUser.ha{ j z f h _ w zsLogin) {
this.; - Y v agetNoticeTimer();
this.checkL[ C l X ` MoginRedirect();
}
} else {
//P ; z假如是初度烘; e e D托,或许运转在client端也或许运转在server端
const projectConfig = await api.getProjectConfig();
this.updaQ l ^ 4 ateState({projectConfig});
//服务端都是游客,无需获取用户信息
if (!isServer()) {
const curUser = await api.getCurUser();
this.dispatch(this.actions.put5 + j ^CurUser(cu^ ] ! ArUser. S e T } k 8));
if (curUsN ~ T p Wer.hasLogin) {
this.getNoticeTimer();
this.checkLoginRA Q F  o vedirect();
}
}
}
}

处理用户登录

咱们只对无需用户登录的页面进行 SSR,所以在 Server 端中用户假定都是游客。在大局的过错处理 Handler 中,遇到需求登录的过错时:

  • 假如当时是 Client 端,则路由到T z , X +登录页或许弹出登录弹窗
  • 假如当时是 Server 端,则直接停止烘托,抛i X 6 2 ]出 303 过错即可。(咱们能够在服务器中 catch 303 过错,直接发送统一的 index.html)
// src/modules/app/model.ts
@effect(null)
protected async [ActionTypes.Error](error:C ( d 0 W U ] T 0 CustomError) {
if (isServer()) {
//服务器中间件会catch 301过错,t I } B R Q ?跳转c d S a | 6URL
if (error.code === CommonErrorCode.redirect) {
throw {code: '3f ~ b 801', detail: error.detail}r . E;
} else {
//服务器直接停止烘托,改为client端烘托
//服务器中间件会catch 303过错,直接发送统一的 index.htmlu f Z 6 u
throw {code: '303'};
}
}
...
}

处理异步} U 6 N + x h = V按需加载

前面说过咱们有必要在 Server 端代码中将模块异步按需加载端y w K t ( a e代码替换成同步。medux 中控制模块同步或异步加载是在src/moduN V { z 2 4les7 d D/i* c ;ndex.ts 中:

// 异步加载
export const moduleG/ @ _ ( Iette& T u X F C Q Y r = {
app: () => {
return import(/* webpackChunkName: "app" */ 'module l m i } pes/app');
},
adminE v V / @ )Layout: () => {
return im] 7 Wport(/* webpack& G Q ` 1 o n s PChunkName: "adminLayout" */ 'modules/admin/adminLayout');
},
...
};
// 替换为同步加载
export const moduleGetter = {
app: () => {
return require('modules/app');
},
adminLayout: () => {
return require('modules/admin/adminLayout');
},
...
};

只需求将import替换为require即可,当然这个简略的替换作业你能够运用本项目供给的一个简略的 webpack-loader 来完成:

@medj O z G ; h G w ~ux/dev-utils/dist/webpack-loader/server-replace-async

它还支撑用参数指定部分 module 替换,以减少 server 端 js 文件的大小,如:

// build/webpack.config.js
{
test: /.(tsx|ts)?$/,
use: [
{
loader: require.resolve('@medux/dev-util^ 3 q ( c H V zs/dist/webpack-loader/server-replace-async5 o 0 y )'),
options: {modules: ['app', 'adz D 8 5 g A d  :minLayout', 'articleLayout', 'artiG % m m 2 scle$ M QHome', '5 h m , K Harticl{ s } j N # neAbout', 'articleService']},
},
{loader: 'babel-loader', options: {cj F H { 5 JacheDirectory: true, caM , j b [ cller: {runtime: 'server'}}},
],
},

其它处理

运用很多细节大家直接看源码吧,有问题能够问我,欢迎共& h ! 2 | F同探讨。