跳到主要内容

为什么要服务端渲染(ssr)

首屏等待

在 SPA 模式下,所有的数据请求和 DOM 渲染都在浏览器端完成,所以当我们第一次访问页面的时候很可能会存在“白屏”等待,而服务端渲染所有数据和 html 内容已在服务端处理完成,浏览器收到的是完整的 html 内容,可以更快地看到渲染内容,在服务端完成数据请求肯定要比浏览器端效率高得多。

SEO

SPA 由于加载模版的时候页面骨架上面只有一个节点,其余所有节点都是由 JS 动态生成的,因为搜索引擎爬虫只认识 html 结构的内容,不能识别 JS 代码内容,所以对于 SEO,完全无能为力。

核心原理

整体来说服务端渲染原理不复杂,核心内容就是同构。

node server 接收到客户端请求,得到当前的 req url path,然后在已有的路有列表内查找对应的组件,拿到需要请求去的数据,将数据作为 props、context 或者 store 形式存入组件,然后基于 react 内置的服务端渲染 api renderToString 或者 renderToNodeStream 把组件渲染为 html 字符串 或者 stream 流,在把最终的 html 进行输出前需要将数据注入到浏览器端(注水),server 输出(response)后浏览器可以得到数据(脱水),浏览器开始进行渲染和节点比对,然后执行组件的 componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点,整个流程结束。

服务端渲染架构图

React SSR

从 ejs 开始

实现 ssr 很简单,先看一个 node ejs 的例子

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title><%= title %></title>
</head>
<body>
<%= data %>
</body>
</html>
// node ssr
const ejs = require("ejs");
const http = require("http");

http
.createServer((rea, res) => {
if (req.url === "/") {
res.writeHead(200, {
"Content-Type": "text/html",
});
ejs.renderFile(
"./views/index.ejs",
{
title: "react ssr",
data: "首页",
},
(err, data) => {
if (err) console.log(err);
else res.end(data);
}
);
}
})
.listen(8080);

JSX 到字符串

参考上面,我们使用 React 组建来实现服务端渲染,使用 jsx 来代替 ejs。

const React = require("react");
const { renderToString } = require("react-dom/server");
const http = require("http");

// 组件
class App extends React.Component {
render() {
return <h1>{this.props.data.title}</h1>;
}
}

// 模拟数据获取
const fetch = function () {
return {
title: "react ssr",
data: [],
};
};

http
.createServer((req, res) => {
if (req.url === "/") {
res.writeHead(200, {
"Content-Type": "text/html",
});
const data = fetch();
const html = renderToString(<Index data={data} />);
res.end(html);
}
})
.listen(8080);

ps:以上代码不能直接运行,需要结合 babel 使用 @babel/preset-react 进行转换

关键问题

在上面非常简单的就实现了 react ssr,它帮助我们引出了一系列关键问题:

双端路由如何维护?

首先我们会发现我在 server 端定义了路由 '/',但是在 react SPA 模式下我们需要使用 react-router 来定义路由,那是不是需要维护两套路由?

获取数据的方法和逻辑写在哪里?

我们发现获取数据的 fetch 方法是独立的,和组件没有任何关联,我们更希望每个路由组件都有自己的 fetch 方法。

服务端 html 节点无法重用

虽然组件在服务端得到了数据,也能渲染到浏览器内,但是当浏览器端进行组件渲染的时候,直出的内容会一闪而过消失。

同构才是核心

react ssr 的核心就是同构,没有同构的 ssr 是没有意义的。

所谓同构就是采用一套代码,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。而传统的服务端渲染无法做到,react ssr 的出现打破了这个瓶颈,并且已经得到了比较广泛的应用。

路由同构

双端使用同一套路由规则,node server 通过 req url path 进行组件查找,得到需要渲染的组件。

// 组件和路由配置,供双端使用 routes-config.js
import React from 'react'

class Detail from React.Component {
render() {
return <div>detail</div>
}
}

class Index from React.Component {
render() {
return <div>index</div>
}
}

const routes = [
{
path: '/',
exact: true,
component: Home
},
{
path: '/detail',
exact: true,
component: Detail
},
{
path: '/detail/:a/:b',
exact: true,
component: Detail
}
]

export default routes
// 客户端路由组件
import routes from "./routes-config.js";

function App() {
return (
<Layout>
<Switch>
{routes.map((item, index) => {
return (
<Route
path={item.path}
key={index}
exact={item.exact}
render={item.component}
/>
);
})}
</Switch>
</Layout>
);
}

export default App;

node server 进行组件查找

路由匹配其实就是对组件 path 规则的匹配,如果规则不复杂可以自己写,如果情况很多种还是使用官方提供的库来完成。

matchRoutes(routes, pathname)

import { matchRoutes } from "react-router-config";
import routes from "./routes-config.js";

http
.createServer((req, res) => {
const url = req.url;
// 简单排出图片等资源文件的请求
if (url.indexOf(".") > -1) {
res.end("");
return false;
}

res.writeHead(200, {
"Content-Type": "text/html",
});

const data = fetch();
// 查找组件
const branch = matchRoutes(routes, url);
// 得到组件
const Component = branch[0].route.component;
// 将组件渲染为 html 字符串
const html = renderToString(<Component data={data} />);
res.end(html);
})
.listen(8080);

matchRoutes 具体返回值查看官方文档

数据同构(预取同构)

这里我们解决【获取数据的方法和逻辑放在哪里?】

数据预取同构,解决双端如何使用同一套数据请求方法来进行数据请求。

在查找到要渲染的组件后,需要预先得到此组件所需要的数据,然后将数据传递给组件后,再进行组件的渲染。

我们可以通过给组件定义静态方法来处理,组件内定义异步数据请求的方法也合情合理,同时声明为 static,在服务端和组件内部都可以通过组件来进行访问,比如:Index.getInitialProps

// 组件
class Index extends React.Component {
static async getInitialProps(opt) {
const fetch1 = await fetch("/xxx.com/a");
const fetch2 = await fetch("/xxx.com/b");
return {
res: [fetch1, fetch2],
};
}

render() {
return <h1>{this.props.data.title}</h1>;
}
}

// 服务端
http
.createServer((req, res) => {
const url = req.url;
if (url.indexOf(".") > -1) {
res.end("");
return false;
}

res.writeHead(200, {
"Content-Type": "text/html",
});

// 组件查找
const branch = matchRoutes(routes, url);
// 得到组件
const Component = branch[0].route.component;
// 数据获取
const data = Component.getinitialProps(branch[0].match.params);
// 传入数据,渲染为 html字符串
const html = renderToString(<Component data={data} />);

res.end(html);
})
.listen(8080);

渲染同构

假设我们现在基于上面已经实现的代码,同时我们也使用了 webpack 进行了配置,对代码进行了转换和打包,整个服务可以跑起来。

路由能够正确匹配,数据预取正常,服务端可以直出组件的 html,浏览器加载 js 代码正常,查看网页源代码能看到 html 内容,好像我们整个流程都已经走完了。

但是当浏览器端的 js 执行完成后,发现数据重新请求了,组件的重新渲染导致页面看上去有些闪烁。

这是因为在浏览器端,双端节点对比失败,导致组件重新渲染,也就是只有服务端和浏览器端渲染的组件具有相同的 props 和 DOM 结构的时候,组件才能只渲染一次。

刚刚我们实现了双端的数据预取同构,但是数据也仅仅是服务端有,浏览器端并没有获取到这份数据,当浏览器进行首次组件渲染的时候没有初始化的数据,渲染出的节点和服务端直出的节点不同,导致组件重新渲染。

数据注水

在服务端将预取的数据注入到浏览器,使浏览器可以访问到,客户端进行渲染前将数据传入对应的组件即可,这样就保证了 props 的一致。

// node server
http
.createServer((rea, res) => {
const url = req.url;
if (url.indexOf(".") > -1) {
res.end("");
return false;
}
res.writeHead(200, {
"Content-Type": "text/html",
});

// 查找组件
const branch = matchRoutes(routes, url);
// 得到组件
const Component = branch[0].route.component;
// 数据预取
const data = Component.getInitialProps(branch[0].match.params);
// 组件渲染
const html = renderToString(<Component data={data} />);
// 数据注水
const propsData = `<textarea style="display: none" id="server-render-data-BOX">${JSON.stringify(
data
)}</textarea>`;
// 通过 ejs 模版引擎将数据注入到页面
ejs.renderFile(
"./index.html",
{
htmlContent: html,
propsData,
},
(err, data) => {
// 渲染数据的key:对应到了ejs中的index
if (err) console.log(err);
else {
console.log(data);
res.end(data);
}
}
);
})
.listen(8080);
<!-- node ejs html -->
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>

<body>
<div id="rootEle">
<%- htmlContent %> <!-- 组件 html 内容 -->
</div>

<%- propsData %> <!-- 组件 init state ,现在是个字符串 -->
</body>

</html>
</body>

需要借助 ejs 模版,将数据绑定到页面上。

数据脱水

上一步数据已经注入到了浏览器端,这一步要在客户端组件渲染前先拿到数据,并且传入组件就可以了。

客户端可以直接使用 id=server-render-data-BOX 进行数据获取

第一个方法简单粗暴,直接在组件构造函数进行获取,后续可以使用高阶组件复用这部分逻辑

第二个方法可以通过 context 传递,只需要在入口处传入,在组件中声明 static contextType 即可,这种方法有利于后续引入 redux。

// 定义 context生产者 组件
import React from "react";
import RootContext from "./route-context";

export default class index extends React.Component {
render() {
return (
<RootContext.Provider value={this.props.initialData || {}}>
{this.props.children}
</RootContext.Provider>
);
}
}

// 入口 app.js
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Routes from "../.";
import Provider from "./provider";

// 渲染入口 接收脱水数据
function renderUI(initialData) {
ReactDOM.hydrate(
<BrowserRouter>
<Provider initialData={initialData}>
<Routes />
</Provider>
</BrowserRouter>,
document.getElementById("root"),
(e) => {}
);
}

// 函数执行入口
function entryIndex() {
let APP_INIT_DATA = {};
let state = false;
// 取得数据
let stateText = document.getElementById("server-render-data-BOX");
if (stateText) {
APP_INIT_DATA = JSON.parse(stateText.value || "{}");
}

if (APP_INIT_DATA) renderUI(APP_INIT_DATA); // 客户端渲染
}

entryIndex(); // 入口执行

通过 context 获取数据

import React from 'react'
import '../css/index.scss'

export default class Index extends React.Component {
constructor(props, context) {
super(props, context)

// 将 context 存储到 state
this.state = {
...context
}
}

// 设置此参数,才能拿到 context 数据
static contextType = RootContext

// 数据预取方法
static async getInitialProps(opt) {
if(__SERVER__) {
// 如果是服务端渲染,可以做的处理,node 端设置全局变量
}
const fetch1 = fetch.postForm('/xxx1')
const fetch2 = fetch.postForm('/xxx2')
const resArr = await fetch.multipleFetch(fetch1, fetch2)

return {
page: {},
fetchData: resArr
}
}

componentDidMount() {
if(!this.isSSR) { // 非服务端渲染需要自身进行数据获取
Index.getInitialProps(this.props.opt).then(data => {
this.setState(data)
})
}

render() {
const { page, fetchData } = this.state
const [ res ] = fetchData || []

return <div>{res && res.data.map(item => {
return <div key={item.id}>{item.name}</div>
})}</div>
}
}

CSS 过滤

我们在写组件的时候大部分都会导入相关的 css 文件,但是 css 文件无法在服务端执行,所以我们需要在服务端渲染的时候将导入的 css 文件去除掉

动态路由

在 SPA 模式下,大部分应用都会实现组件分包和按需加载,为了防止所有代码打包在一个文件,导致资源过大影响页面的加载和渲染。

在 SSR 下,我们限定按需的粒度是路由级别,请求不同的路由动态加载对应的组件。

如何实现组件的按需加载?

在 webpack 2 时期主要是用 require.ensure 方法来实现,在当下 webpack 4,有了更加规范的方式实现按需加载,那就是 import('xx.js')

我们都知道 import 方法传入一个 js 文件地址,返回值是一个 promise 对象,然后再回调中得到按需的组件。它的原理其实就是通过 jsonp 的方式,动态请求脚本,然后在回调内得到组件。

import("../index").then((res) => {
// xxx
});

所以我们得到了几个比较有用的信息:

  • 如何加载脚本:import 结合 webpack 自动完成;
  • 脚本是否加载完成:通过 then 方法回调进行处理;
  • 获取异步按需组件:通过 then 方法回调获取。
// 使用 react-loadable
import React from "react";
import Loadable from "react-loadable";

// index.js
class Index extends React.Component {
render() {
return <div>index</div>;
}
}

// detail.js
class Detail extends React.Component {
render() {
return <div>detail</div>;
}
}

// route.js
// 按需加载 index 组件
const AsyncIndex = (props) => {
return (
<Async load={() => import("../index")}>{(C) => <C {...props} />}</Async>
);
};

// 按需加载 detail 组件
const AsyncDetail = (props) => {
return (
<Async load={() => import("../detail")}>{(C) => <C {...props} />}</Async>
);
};

const routes = [
{
path: "/",
exact: true,
component: AsyncIndex,
},
{
path: "/detail",
exact: true,
component: AsyncDetail,
},
];

按照这种方式进行配置,会发现 ssr 无效了。

动态路由 SSR 双端配置

ssr 无效的原因:

因为我们做路由同构的时候,双端使用的是同一个 route 配置文件 routes-config.js,现在组件修改成了按需加载,所以在路由查找后得到的组件发生了改变,AsyncDetail 和 AsyncIndex 无法转换出组件内容。

ssr 模式下服务端如何处理路由按需加载

参考客户端的处理方式,对路由配置二次处理,服务端在组件查找前强制指向 import 方法,得到一个全新的静态路由表,然后再去查找组件。

import routes from 'routes-config.js'

export async function getStaticRoutes() {
const staticRoutes = []

for(const route of routes) {
staticRoutes.push({
...route,
{
component: (await item.component().props.load()).default
}
})
}
return staticRoutes
}

ssr 模式下客户端如何处理路由按需加载

完成服务端配置后,浏览器会一直显示加载中,这是因为双端节点对比失败,重新渲染。

解决办法

等待按需组件加载完成后再进行渲染。

如何按需

参考服务端设置,不过不转换成静态路由表,只需要找到按需加载组件完成动态加载即可。