Web rendering
先从网页的浏览说起
以下是一些网页性能相关的名词,以后会引用:
TTFB
:Time to First Byte
- 被视为点击链接和第一部分内容之间的时间。
FP
:First Paint
- 任何像素第一次对用户可见的时间。
FCP
:First Contentful Paint
- 请求的内容(文章正文等)变得可见的时间。
TTI
:Time To Interactive
- 页面变得可交互的时间(连接的事件等)。
SSR
首先就是SSR:Server-Side Rendering
。
SSR
有两种模式,单页面和非单页面模式,第一种是后端首次渲染的单页面应用,是在首次加载的时候,后端进行当前路径页面的组件渲染和数据请求,组装成HTML
返回给前端,用户就能很快看到看到页面,当HTML
中的JS
资源加载完成后,剩下执行和运行的就是一般的单页面应用。
第二种是完全使用后端路由的后端模版渲染模式。他们区别在于使用后端路由的程度。
SSR
的两个明显的优势:首次加载快和SEO
。
为什么说首次加载快呢。 一个普通的单页面应用,首次加载的时候需要把所有相关的静态资源加载完毕,然后核心JS
才会开始执行,这个过程就会消耗一定的时间,接着还会请求网络接口,最终才能完全渲染完成。
SSR
模式下,后端拦截到路由,找到对应组件,准备渲染组件,所有的JS
资源在本地,排除了JS
资源的网络加载时间,接着只需要对当前路由的组件进行渲染,而页面的ajax
请求,可能在同一台服务器上,如果是的话速度也会快很多。最后后端把渲染好的页面反回给前端。
注意:页面能很快的展示出来,但是由于当前返回的只是单纯展示的DOM
、CSS
,其中的JS
相关的事件等在客户端其实并没有绑定,所以最终还是需要JS
加载完以后,对当前的页面再进行一次渲染,称为同构。 所以SSR
就是更快的先展示出页面的内容,先让用户能够看到。
为什么SEO
友好呢,因为搜索引擎爬虫在爬取页面信息的时候,会发送HTTP
请求来获取网页内容,而我们服务端渲染首次的数据是后端返回的,返回的时候已经是渲染好了title
,内容等信息,便于爬虫抓取内容。
SSR
可能需要一点时间来准备内容(较长的TTFB
),但是有很快的FCP
以及TTI
。
CSR
随着前端功能和互动越来越复杂,我们需要Client-Side Rendering
和SPA
的架构来构建富有互动性的网页:Angular
,React
和Vue
等等。
Server
只回传没有内容的html
,等到JS
加载完成再根据不同的url
渲染页面,后续的页面的都是在前端,用JS
来渲染页面。
CSR
和SPA
大幅增进了前端开发的体验,以及页面的互动性,但是问题也随之而出:
- 越来越大的
Javascript Bundle Size
- 一开始的页面空白,需要等
JS
加载执行才有内容,有可能不利于SEO
其中肥大的Javascript Code
,造成加载和执行的速度变慢。
FCP
,TTI
时间变长,意味着使用者有很长时间看到的是空白或者不完整、还无法互动的页面。
关于SSR
单纯的SSR
只是展示静态内容的传统技术,我们仍需要 SPA 交互体验。
最好的方案就是SSR
+SPA
相结合,即在实现服务端渲染的时候,还要实现客户端渲染,首次访问页面是服务端渲染,基于首次访问的后续的交互就是SPA
的效果,这样就保留了两个技术的优点。
两种技术有大量可重用的代码,客户端路由、服务器端路由、客户端Redux
、服务器端Redux
等,最大程度的复用这些代码,就是同构。
现在所说的服务端渲染基本上都是SSR
+SPA
的同构渲染,不是传统上的服务端渲染。
简单来看一下ssr-core-react
这种框架会是什么样子:
import { render } from 'ssr-core-react'
@Get('/')
@Get('/detail/:id')
async handler (): Promise<void> {
try {
this.ctx.apiService = this.apiService
this.ctx.apiDeatilservice = this.apiDeatilservice
const stream = await render<Readable>(this.ctx, {
stream: true
})
this.ctx.body = stream
} catch (error) {
console.log(error)
this.ctx.body = error
}
}
当访问http://localhost:3000
或者http://localhost:3000/detail/xxx
时,请求会首先经过在Controller
中注册的路由。并且交由对应的函数进行处理。
示例函数的处理逻辑,调用了ssr-core-xxx
模块提供的render
方法,来渲染当前请求所对应的前端组件。并且将返回的结果是一个包含html
, meta
标签的完整html
文档结构。返回的文档结构中已经包含了script
标签加载客户端资源的相关代码。
render
方法:
数据请求将会在服务端渲染执行的过程中被调用。在客户端激活的过程中会复用服务端获取并注入到 window
中的数据来进行初始化。不会在客户端再次获取。当客户端进行前端路由切换时会调用将要前往的页面对应的fetch
。
实现思想
核心实现分为以下几步:
- 后端拦截路由,根据路径找到需要渲染的
react
页面组件X
- 调用组件
X
初始化时需要请求的接口,同步获取到数据后,使用react
的renderToString
方法对组件进行渲染,使其渲染出节点字符串。 - 后端获取基础
HTML
文件,把渲染出的节点字符串插入到body
之中,同时也可以操作其中的title
,script
等节点。返回完整的HTML
给客户端。 - 客户端获取后端返回的
HTML
,展示并加载其中的JS
,最后完成react
同构。
-
注册路由
import Index from "../pages/index";
import List from "../pages/list";
const routers = [
{ exact: true, path: "/", component: Index },
{ exact: true, path: "/list", component: List }
];
配置路由路径和组件的映射,使其能被客户端路由和服务端路由同时使用。
import Index from "../pages/index";
import List from "../pages/list";
const routers = [
{ exact: true, path: "/", component: Index },
{ exact: true, path: "/list", component: List }
];
//注册页面和引入组件,存在对象中,server路由匹配后渲染
export const clientPages = (() => {
const pages = {};
routers.forEach(route => {
pages[route.path] = route.component;
});
return pages;
})();
export default routers;
服务端处理
import { clientPages } from "./../../client/router/pages";
router.get("*", (ctx, next) => {
let component = clientPages[ctx.path];
if (component) {
const data = await component.getInitialProps();
//因为component是变量,所以需要create
const dom = renderToString(
React.createElement(component, {
ssrData: data
})
)
}
})
匹配到组件以后,执行了组件的getInitialProps
方法,此方法是一个封装的静态方法,主要用于获取初始化所需要的ajax
数据,在服务端会同步获取,而后通过ssrData
参数传入组件props
并执行组件渲染。 此方法在客户端依然是异步请求。
-
客户端渲染
import React from "react";
export default class Base extends React.Component {
//override 获取需要服务端首次渲染的异步数据,也可以是api请求
static async getInitialProps() {
return null;
}
static title = "react ssr";
//page组件中不要重写constructor
constructor(props) {
super(props);
//如果定义了静态state,按照生命周期,state应该优先于ssrData
if (this.constructor.state) {
this.state = {
...this.constructor.state
};
}
//如果是首次渲染,会拿到ssrData
if (props.ssrData) {
if (this.state) {
this.state = {
...this.state,
...props.ssrData
};
} else {
this.state = {
...props.ssrData
};
}
}
}
async componentWillMount() {
//客户端运行时
if (typeof window != "undefined") {
if (!this.props.ssrData) {
//静态方法,通过构造函数获取
const data = await this.constructor.getInitialProps();
if (data) {
this.setState({ ...data });
}
}
//设置标题
document.title = this.constructor.title;
}
}
}
如果在客户端环境,分两种情况。
第一种:用户第一次进到页面,这时候是服务端去请求的数据,服务端获取到数据后在服务端渲染组件,同时也会把数据存放在HTML
的script
代码中,定义一个全局变量ssrData
,react
在注册单页面应用并且同构的时候会把全局ssrData
传递给页面组件,这个时候页面组件在客户端同构渲染的时候,就可以延续使用服务端之前的数据,这样也保持了同构的一致性,也避免了一次重复请求。
第二种情况:就是当前用户在单页面之中切换路由,这样就没有服务端渲染,那么就执行 getInitialProps
方法,把数据直接返回给state
,几乎等同于在willmount
中执行请求。
最重要的问题来了!!----怎么区分第一次和之后的渲染?就是什么时候传递ssrData, 逻辑如下:
import { BrowserRouter, Route, Switch, withRouter } from "react-router-dom";
import React from "react";
import routers from "./pages";
const router = ({ ssrData, ssrPath }) => {
//把ssr数据注入到所有页面中,第一个路由的页面接收到以后,其他页面需要废弃使用当前数据,并且调用getInitialProps方法初始化
routers.forEach(item => {
let _ssrData = null;
//如果当前路由注册并且首次渲染的路径匹配,给组件注入ssrData(这段代码只会在首次加载,路由注册时才执行)
if (ssrPath == item.path) {
_ssrData = ssrData;
}
item.render = () => {
item.component = withRouter(item.component); //注入路由信息
return <item.component ssrData={_ssrData} />;
};
});
return (
<BrowserRouter>
<Switch>
{routers.map((route, i) => (
<Route
key={i}
exact={route.exact}
path={route.path}
render={route.render}
/>
))}
</Switch>
</BrowserRouter>
);
};
export default router;
-
脱水(dehydrate)
准备index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>/*title*/</title>
</head>
<body>
<div id="root">$$$$</div>
<script>
/*getInitialProps*/
</script>
<script src="/*app*/"></script>
<script src="/*vendor*/"></script>
</body>
</html>
替换节点
indexHtml = indexHtml.replace("/*title*/", component.title);
indexHtml = indexHtml.replace(
"$$$$",
renderToString(
React.createElement(component, {
ssrData: data
})
)
);
indexHtml = indexHtml.replace(
"/*getInitialProps*/",
`window.ssrData=${JSON.stringify(data)};window.ssrPath='${ctx.path}'`
);
indexHtml = indexHtml.replace("/*app*/", bundles.app);
indexHtml = indexHtml.replace("/*vendor*/", bundles.vendor);
ctx.response.body = indexHtml;
next();
组件被序列化成了静态的 HTML 片段,还能看出来模样,不过已经无法与之交互了。
-
注水(hydrate)
客户端JS
加载完成后,会运行react
,并且执行同构方法ReactDOM.hydrate
,而不是平时用的 ReactDOM.render
。
react-dom
提供的hydrate
方法类似render
方法,用于二次渲染。
它在渲染的时候会复用原本已经存在的
DOM
节点,减少重新生成节点以及删除原本DOM
节点的开销,只进行事件处理绑定。
hydrate
和render
的区别就是hydrate
会复用已有节点,render
会重新渲染全部节点。
所以
hydrate
主要用于二次渲染服务端渲染的节点,提高首次加载体验。
import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
render() {
return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
}}
hydrate(
<App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
document.getElementById("root")
);