服务器渲染(Server-Side Rendering)并非一个复杂的技术,而 服务器渲染
与 服务器同构渲染
则是2个不一样的概念,重点在于:同构,要作到一套代码完美的运行在浏览器与服务器之上不是一件简单的事情,目前业界也没有特别满意的方案,都须要或多或少的对不一样的环境作差别化处理。javascript
一般同构渲染主要是为了:css
单页(SPA)
和多页路由
的用户体验一般同构渲染须要作到:html
单页(SPA)
的用户体验。多页路由
的用户体验。同构渲染的主要难点在于 Client 端渲染时组件生命周期钩子承载了太多的职能与反作用,好比:获取数据、路由、按需加载、模块化等等,这些逻辑被分散在各个组件中随着组件的渲染动态执行,而它们的执行又再次引发组件的从新渲染。简单来讲就是:java
Render -> Hooks -> Effects -> ReRender -> Hooks -> Effects...node
这样的渲染流程在 Server 端是不行的,由于一般 Sever 端不会 ReRender,所以必须把全部反作用都提早执行,然后在一次性 Render,简单来讲就是:react
Effects -> State -> Renderwebpack
那么解决方案就是将这些反作用尽可能的与组件的生命周期钩子脱离,并引入独立的状态管理机制来管理它们,让 UI 渲染变成简单纯粹的 PrueRender,而这正是@medux 所倡导的状态驱动
理念。nginx
在 Client 端渲染时,为了提高加载速度咱们一般对代码进行 chunk 分包、并使用异步按需加载
来优化用户体验。而在 Server 端渲染时这变得彻底不必,反而会拖慢加载速度。如何在 server 端中替换异步代码为同步代码呢?正好@medux将模块加载视为一种配置策略,它能够很轻松的让将模块加载在同步和异步之间切换。git
本项目 fork 自medux-react-admin,这是一个使用 Medux+React+Antd4+Hooks+Typescript 开发的 WEB 单页应用,你能够从本项目中看到如何将一个 SinglePage(单页应用
) 快速转换为支持 SEO 的多页应用。github
项目地址:medux-react-ssr
打开如下页面,使用鼠标右键点击“查看网页源码”,看是否输出了 Html
// 注意一下,由于本项目风格检查要求以 LF 为换行符
// 因此请先关闭 Git 配置中 autocrlf
git config --global core.autocrlf false
git clone https://github.com/wooline/medux-react-ssr.git
cd medux-react-ssr
yarn install
复制代码
yarn start
,会自动启动一个开发服务器。这是一个典型的后台管理系统,页面主要分为 2 类:
咱们之因此要使用 SSR 改造它主要是为了让第二类页面能被搜索引发收录(SEO),而对于第一类页面,由于须要用户登陆,因此对于搜索引擎也没什么意义,咱们依然沿用纯浏览器渲染就好。
既然是同构,咱们固然不但愿为 2 端平台作太多的差别化处理,可是仍是会有少量的定制代码。好比启动入口,原来是./src/index.ts
,如今咱们须要将其区分为:
利用这 2 个不一样的入口,咱们集中构建一些 shim,抹平一些平台的差别化。
运行在 Sever 端的代码无需异步按需加载、无需处理 CSS、无需处理图片等等,因此咱们使用 2 套 webpack 配置来进行编译打包并分别输出在 dist/client/
和 dist/server/
目录下。
对于 Sever 端的输出其实就只有一个 main.js 文件。
怎么部署和运行编译后输出的代码?本项目编写了一个 express 的简单样例可供参考,目录结构大体为这样:
dist
├── package.json // 运行须要的依赖
├── start.js // nodejs启动入口
├── pm2.json // pm2部署配置
├── nginx.conf // nginx配置样例
├── env.json // 运行环境变量配置
├── 404.html // 404错误页面
├── 50x.html // 500错误页面
├── index.html // SSR模版
├── mock // mock假数据目录
├── html // 生成的纯静态化页面目录
│ ├── login.html
│ ├── register.html
│ └── article
├── server // Server端输出目录
│ └── main.js // SSR主程代码
├── client // Client端输出目录
│ ├── css // 生成的CSS文件目录
│ ├── imgs // 未经工程化处理的图片目录
│ ├── media // 经webpack处理过的图片目录
│ └── js // 浏览器运行JS目录
复制代码
对于 dist/client 就是一个静态目录,你可使用 Nginx 部署
对于 dist/server 其实就是一个 JS Module 文件 main.js
,它只有一个default export
的方法:
export default function render( location: string ): Promise<{
html: string | ReadableStream<any>;
data: any;
ssrInitStoreKey: string;
}>;
复制代码
你可使用任意 node 服务器(如 express)来执行它,并获得渲染后的 data、html、已及脱水数据的key
。至于你要如何让服务器输出这些结果,以及如何处理执行过程出现的异常和错误,你能够自由发挥,例如:
前面咱们说过应用在 Server 端运行的流程是:Effects -> State -> Render,也就是说:先获取数据,再渲染组件
。
在 medux 框架中数据处理是封装在 model 中的,而初始化数据一般是在 model 中经过监听 module.Init
这个 Action 来执行 Effect,从而处理数据并转化为 moduleState。当一个 module 被加载时,不论 Client 端仍是 Server 端都会触发这个 Action,因此在这个 ActionHandler 中咱们要注意的是:若是 Server 端已经作过的工做,Client 端不必再重复作了。能够经过moduleState.isHydrate
来判断当前的 moduleState 是否已是服务器处理过,例如:
// src/modules/app/model.ts
@effect(null)
protected async ['this.Init']() {
if (this.state.isHydrate) {
//若是已经通过SSR服务器渲染,那么getProjectConfig()无需执行了
const curUser = await api.getCurUser();
this.dispatch(this.actions.putCurUser(curUser));
if (curUser.hasLogin) {
this.getNoticeTimer();
this.checkLoginRedirect();
}
} else {
//若是是初次渲染,可能运行在client端也可能运行在server端
const projectConfig = await api.getProjectConfig();
this.updateState({projectConfig});
//服务端都是游客,无需获取用户信息
if (!isServer()) {
const curUser = await api.getCurUser();
this.dispatch(this.actions.putCurUser(curUser));
if (curUser.hasLogin) {
this.getNoticeTimer();
this.checkLoginRedirect();
}
}
}
}
复制代码
咱们只对无需用户登陆的页面进行 SSR,因此在 Server 端中用户假定都是游客。在全局的错误处理 Handler 中,遇到须要登陆的错误时:
// src/modules/app/model.ts
@effect(null)
protected async [ActionTypes.Error](error: CustomError) {
if (isServer()) {
//服务器中间件会catch 301错误,跳转URL
if (error.code === CommonErrorCode.redirect) {
throw {code: '301', detail: error.detail};
} else {
//服务器直接终止渲染,改成client端渲染
//服务器中间件会catch 303错误,直接发送统一的 index.html
throw {code: '303'};
}
}
...
}
复制代码
前面说过咱们必须在 Server 端代码中将模块异步按需加载端代码替换成同步。medux 中控制模块同步或异步加载是在src/modules/index.ts
中:
// 异步加载
export const moduleGetter = {
app: () => {
return import(/* webpackChunkName: "app" */ 'modules/app');
},
adminLayout: () => {
return import(/* webpackChunkName: "adminLayout" */ 'modules/admin/adminLayout');
},
...
};
// 替换为同步加载
export const moduleGetter = {
app: () => {
return require('modules/app');
},
adminLayout: () => {
return require('modules/admin/adminLayout');
},
...
};
复制代码
只须要将import
替换为require
便可,固然这个简单的替换工做你可使用本项目提供的一个简单的 webpack-loader 来完成:
@medux/dev-utils/dist/webpack-loader/server-replace-async
它还支持用参数指定部分 module 替换,以减小 server 端 js 文件的大小,如:
// build/webpack.config.js
{
test: /\.(tsx|ts)?$/,
use: [
{
loader: require.resolve('@medux/dev-utils/dist/webpack-loader/server-replace-async'),
options: {modules: ['app', 'adminLayout', 'articleLayout', 'articleHome', 'articleAbout', 'articleService']},
},
{loader: 'babel-loader', options: {cacheDirectory: true, caller: {runtime: 'server'}}},
],
},
复制代码
使用不少细节你们直接看源码吧,有问题能够问我,欢迎共同探讨。