SSR介绍 + NodeJS服务端渲染调研报告

6,248 阅读7分钟

来源: FE研发(上海) 团队 - 葛婷

一、 服务端渲染(Server-Side Render)介绍

什么是SSR?为什么要用SSR?

客户端渲染(Client-Side Render):客户端渲染,页面初始加载的 HTML 页面中无网页展示内容,需要加载执行 JavaScript 文件中的 React 代码,通过 JavaScript 渲染生成页面,同时,JavaScript 代码会完成页面交互事件的绑定。 服务端渲染:所有数据请求和 html 内容已在服务端处理完成,浏览器收到的是完整的 html 内容,可以更快的看到渲染内容,在服务端完成数据请求肯定是要比在浏览器端效率要高的多。

SSR + SPA

CSR 的痛点:

  1. CSR 项目的 TTFP(Time To First Page)时间比较长,在 CSR 的页面渲染流程中,首先要加载 HTML 文件,之后要下载页面所需的 JavaScript 文件,然后 JavaScript 文件渲染生成页面。在这个渲染过程中至少涉及到两个 HTTP 请求周期,所以会有一定的耗时,这也是为什么大家在低网速下访问普通的 React 或者 Vue 应用时,初始页面会有出现白屏的原因。
  2. CSR 项目的 SEO 能力极弱,在搜索引擎中基本上不可能有好的排名。因为目前大多数搜索引擎主要识别的内容还是 HTML,对 JavaScript 文件内容的识别都还比较弱。如果一个项目的流量入口来自于搜索引擎,这个时候你使用 CSR 进行开发,就非常不合适了。

SSR 的产生,主要就是为了解决上面所说的两个问题。在 React 中使用 SSR 技术,我们让 React 代码在服务器端先执行一次,使得用户下载的 HTML 已经包含了所有的页面展示内容,这样,页面展示的过程只需要经历一个 HTTP 请求周期,TTFP 时间得到一倍以上的缩减。 同时,由于 HTML 中已经包含了网页的所有内容,所以网页的 SEO 效果也会变的非常好。之后,我们让 React 代码在客户端再次执行,为 HTML 网页中的内容添加数据及事件的绑定,页面就具备了 React 的各种交互能力。

因此,最好的方案就是这两种体验和技术的结合,第一次访问页面是服务端渲染,基于第一次访问后续的交互就是 SPA 的效果和体验,还不影响SEO 效果。而要实现两种技术的结合,其核心原理是 同构

什么是同构?

同构这个概念存在于 Vue,React 这些新型的前端框架中,同构实际上是客户端渲染和服务器端渲染的一个整合。我们把页面的展示内容和交互写在一起,让代码执行两次。在服务器端执行一次,用于实现服务器端渲染,在客户端再执行一次,用于接管页面交互。

二、实现原理

首先我们需要两套 webpack 配置,一套用于编译生成服务端执行的脚本,一套用于编译生成客户端的脚本。

路由

服务端路由需要使用 react-router 库的 StaticRouter,而客户端路由需要使用 BrowserRouter

// 服务端
const sheet = new ServerStyleSheet();
const str = ReactDOMServer.renderToString(
	sheet.collectStyles(
		<StaticRouter location={req.url} context={{}}>
			<MobileSiteAppWrapper>
				<Switch>
					{routes.map(route=>
						<Route
							key={route.path}
							path={route.path}
							render={props=> {
								const Comp = route.component;
								return <Comp {...props} data={data}/>;	// 服务端注入数据
							}}
							exact={route.exact}
						/>)}
				</Switch>
			</MobileSiteAppWrapper>
		</StaticRouter>
	 )
);
const styles = sheet.getStyleTags();
// 客户端
const App = () => (
	<BrowserRouter>
		<MobileSiteAppWrapper>
			<Switch>
				{routes.map((route, i) => {
					return (
						<Route
							key={i}
							path={route.path}
							render={props=> {
								const Comp = route.component;
								return <Comp {...props} data={getInitData()} />;	// 客户端注入数据
							}}
							exact={route.exact}
						/>
					);
				})}
			</Switch>
		</MobileSiteAppWrapper>
	</BrowserRouter>
);

注入CSS

使用了 style-components 库的 ServerStyleSheet 提取样式后,渲染模板时注入 html 文件中

服务端获取数据

以详情页为例,我们需要在服务端请求接口后,将得到的数据注入组件内,然后在组件的 构造函数 中将数据保存进组件 state 或 store 中。

组件的生命周期在服务端渲染的过程中不会执行

最终返回完整的 html

ejs.renderFile("server/template.ejs", {
	styles,
	comp: str,
	mainjs: manifestFile["main.js"],
}, (err, data) => {
	if (err) {
		console.error(err);
	} else {
		res.end(data);
	}
});

到此,我们就已经在服务端渲染出带有数据的、看上去和原先完全一致的 html 页面了。

浏览器端渲染

但是服务运行起来之后,会发现一个新的问题,当浏览器端的 js 执行完成后,我们又重新请求了一次数据,并且引发了组件的重新渲染。

这是因为在浏览器端,双端节点对比失败,导致组件重新渲染,也就是只有当服务端和浏览器端渲染的组件具有相同的 props 和 DOM 结构的时候,组件才能只渲染一次。 刚刚我们在服务器端获取了数据,但也仅仅是服务端有,浏览器端是没有这个数据的,当客户端进行首次组件渲染的时候没有初始化的数据,渲染出的节点肯定和服务端直出的节点不同,导致组件重新渲染。 在服务端将预取的数据注入到浏览器,使浏览器端可以访问到,客户端进行渲染前将数据传入对应的组件即可,这样就保证了 props 的一致。

// ejs 模板新增
<textarea style="display: none;" id="ssr-data"><%= data %></textarea>

ejs.renderFile("server/template.ejs", {
	styles,
	comp: str,
	mainjs: manifestFile["main.js"],
	data: data ? JSON.stringify(data) : "",	// 新增
}, (err, data) => {
	if (err) {
		console.error(err);
	} else {
		res.end(data);
	}
});
// 在客户端 js 首次执行时,尝试读取一次服务端注入在 html 中的数据(读取到后移除 dom),然后将数据同样传入组件中
export function getSSRData() {
	let INIT_DATA = null;
	try {
		let stateNode = document.getElementById("ssr-data") as HTMLTextAreaElement;
		if (stateNode && stateNode.value) {
			INIT_DATA = JSON.parse(stateNode.value);
			stateNode.parentNode.removeChild(stateNode);
		}
	} catch (e) {
		console.error(e);
	}
	return INIT_DATA;
}
// 组件内部
constructor (props) {
	if (props.data) {
		// 直接将数据填入 state 或 store 中
	} else {
		// 和原先一样请求数据
	}
}

这样,在浏览器端 js 执行后,页面应该就不会重复请求数据及渲染了。但实际项目应用中时,可能发现并非如此,仍会出现页面闪烁,这是因为之前项目中使用了按需加载的动态导入进行过优化。

动态路由

原项目中,采用了按需加载和代码分离进行优化,如下所示:

component: Loadable({
	loader: () => import(/* webpackChunkName: "ProductDetail" */"./routes/ProductDetail"),
	loading: LoadingComponent
}),

浏览器端 js 执行时,会先显示LoadingComponet,等对应的 chunk js 下载执行完毕后,再渲染出对应页面。因此,我们需要对 SSR 做额外的处理,在匹配上路由后,先保证对应chunk js preload 完毕,再进行页面的渲染。

// ssr.ts
export async function mountComponent(path = location.pathname) {
	let matchedRoute: RouteParams = null;
	routes.some(route => {
		if (matchPath(path, { path:route.path, exact:route.exact })) {
			matchedRoute = route;
			return true;
		}
		return false;
	});
	if (matchedRoute) {
		return matchedRoute.component.preload();
	} else {
		return Promise.resolve();
	}
}

// index.tsx
if (__SSR__) {
	const { mountComponent } = require("./ssr");
	mountComponent().then(() => {
		ReactDOM.hydrate(<App />, document.getElementById("app"));
	});
} else {
	ReactDOM.render(<App />, document.getElementById("app"));
}

三、总结

好处:

  • 优化用户体验,解决首屏白屏的问题,用户能够更快的看到实际页面内容
  • 利于SEO

SSR 改造面临的问题:

  • 现前端项目中,存在大量直接使用浏览器端全局变量的代码(例如: window.location / window.document 等),无法直接用于服务端渲染,必须改造
  • 需要做 SSR 的页面,都需要分别对相应组件的数据获取相关逻辑做改造,以上都需要不小的成本
  • 引入 SSR 后,前端开发时需要考虑兼容双端逻辑,开发成本提高

NodeJS 作为服务端的问题:

  • nodejs 做 React 的 SSR 很好做,但公司没有相应技术栈,使用 nodejs 做为服务端需要一整套解决方案,从开发调试到部署监控,现阶段出问题没有保障

参考链接: