React混合SSR与SPA片段
单页面应用(SPA)在处理大数据与强交互的方面有优势,但是其缺点是首屏加载比较慢、很难进行搜索引擎优化(SEO)。在工具软件上,选择SPA是正确的。但是,以静态内容展现为主的网站,如果选择SPA,给用户的体验不好。前端使用React框架时,可以选择服务端渲染(SSR)的方式解决该问题。
大部分网站,即使是以静态内容展现为主的网站,其中也包含一部分动态内容与交互。本文就演示:使用React服务端渲染的同时,也在前端动态渲染一些组件(为了让只搞过SPA的同学更容易理解,暂时称之为“SPA片段”),目的是完成动静分离、页面骨架快速显示。同时,“SPA片段”这种方法,可以用来逐渐替换“祖传”的老代码到React组件。导出静态路由之后,还可以使用SSR来生成页面缓存或静态页面。
在写本文之前,我也看了: https://www.digitalocean.com/community/tutorials/react-react-router-ssr , 它讲得比较详细。但是,本文的侧重点跟它不一样。
本文样例代码,见: https://github.com/kinsprite/react-mix-ssr-spa
1. 构建混合项目
我们还是使用TypeScript做主要开发语言,ES6只在NodeJS服务脚本中使用。以react-micro-frontend-scripts做构建脚本,entry中添加ssr
与spa
两个入口文件。
仅验证技术的可行性,只完成发布脚本build.js适配,本地开发脚本start.js未支持,HTML拼接时也未做安全过滤。构建时,执行yarn build
;运行SSR服务时,再执行node .
。
// scripts/build.js
const scripts = require('react-micro-frontend-scripts');
function build() {
process.env.PUBLIC_URL = '/';
// --- ENV for ALL ---
process.env.ENSURE_NO_EXPORTS = 'true';
process.env.SPLIT_CHUNKS = 'false';
process.env.RUNTIME_CHUNK = 'false';
scripts.runWebpack(scripts.envProduction, (config) => ({
...config,
externals: {
fs: 'fs',
http: 'http',
path: 'path',
},
entry: {
ssr: scripts.resolvePath('src/ssr'),
spa: scripts.resolvePath('src/spa'),
},
}));
}
build();
2. SSR主要文件
服务端渲染成HTML文档,也进行SEO、插入动态SPA片段脚本,见ssr.tsx文件:
// src/ssr.tsx
import ReactDOMServer from 'react-dom/server';
import http from 'http';
import RouterBase, { getMetaObj } from './RouterBase';
import './root.css';
interface ManifestJson {
files: {[key:string]: string};
}
interface SSRParam {
context : {url?: string};
req: http.IncomingMessage;
indexHtml: string;
manifestJson: ManifestJson;
}
function getMetaAndTitle(url: string): string {
const meta = getMetaObj(url);
if (!meta) {
return '';
}
let res = '';
res += `<meta name="description" content="${meta.description}" />`;
res += `<meta name="keywords" content="${meta.keywords}" />`;
res += `<title>${meta.title}</title>`;
return res;
}
function getLinks(manifestJson: ManifestJson) : string {
let links = '';
const { files } = manifestJson;
if (files['ssr.css']) {
links += `<link rel="stylesheet" type="text/css" href="${files['ssr.css']}">`;
}
if (files['spa.css']) {
links += `<link rel="stylesheet" type="text/css" href="${files['spa.css']}">`;
}
if (files['spa.js']) {
links += `<link rel="preload" as="script" href="${files['spa.js']}">`;
}
return links;
}
function getScripts(manifestJson: ManifestJson) : string {
let scripts = '';
const { files } = manifestJson;
if (files['spa.js']) {
scripts += `<script src="${files['spa.js']}"></script>`;
}
return scripts;
}
function runSSR({
context, req, indexHtml, manifestJson,
} : SSRParam) : string {
const html = ReactDOMServer.renderToString(RouterBase(req, context));
if (context.url) {
return html;
}
return indexHtml.replace(/<title>.+<\/title>/, '')
.replace('</head>', `${getMetaAndTitle(req.url)}${getLinks(manifestJson)}</head>`)
.replace('</body>', `${getScripts(manifestJson)}</body>`)
.replace('<div id="root"></div>', `<div id="root">${html}</div>`);
}
export default runSSR;
静态路由文件:
// src/RouterBase.tsx
import http from 'http';
import React from 'react';
import {
StaticRouter as Router,
Switch,
Route,
Redirect,
} from 'react-router-dom';
import AppExample from './AppExample';
import Home from './home';
function RouterBase(req: http.IncomingMessage, context : { url?: string}) : JSX.Element {
return (
<Router location={req.url} context={context}>
<Switch>
<Route path="/home">
<Home />
</Route>
<Route path="/app-example">
<AppExample />
</Route>
<Route path="*">
<Redirect to="/home" />
</Route>
)
</Switch>
</Router>
);
}
interface MetaObj {
description: string;
keywords: string;
title: string;
}
const metaMap: {[url: string]: MetaObj} = {
'/home': {
description: 'Home description',
keywords: 'Home',
title: 'Home',
},
'/app-example': {
description: 'App example description',
keywords: 'App, Example',
title: 'App Example',
},
};
function getMetaObj(url: string) : MetaObj {
return metaMap[url];
}
export default RouterBase;
export {
getMetaObj,
};
NodeJS SSR服务启动文件:
// index.js
/* eslint-disable @typescript-eslint/no-var-requires, import/no-dynamic-require, import/no-extraneous-dependencies */
const fs = require('fs');
const http = require('http');
const path = require('path');
const connect = require('connect');
const serveStatic = require('serve-static');
const manifestJson = require(path.resolve('./dist/rmf-manifest.json'));
const runSSR = require(path.resolve(path.join('./dist', manifestJson.files['ssr.js'])));
function readIndexHtml() {
const indexFile = path.resolve('./public/index.html');
const result = fs.readFileSync(indexFile, 'utf8');
if (result) {
return result;
}
return `<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<div id="root"></div>
</body>
</html>`;
}
const indexHtml = readIndexHtml();
function renderHtml(req, res) {
// This context object contains the results of the render
const context = {};
const html = runSSR.default({
context, req, indexHtml, manifestJson,
});
// context.url will contain the URL to redirect to if a <Redirect> was used
if (context.url) {
res.writeHead(302, {
Location: context.url,
});
res.end();
} else {
res.write(html);
res.end();
}
}
const app = connect();
app.use(serveStatic('dist', { index: false }));
app.use(serveStatic('public', { index: false }));
// respond to all requests
app.use(renderHtml);
// create node.js http server and listen on port
console.log('Listen on: http://127.0.0.1:3000');
http.createServer(app).listen(3000);
使用Connect做后端服务,添加静态文件服务与renderHtml()
函数即可。
3. 渲染SPA片段
在JS加载完成时,查找符合规则的DOM元素并进行ReactDOM.render()。
// src/spa.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import Counter from './Counter';
function DynamicView1() {
return (
<>
<Counter />
</>
);
}
function DynamicView2() {
return (
<>
<p>Dynamic View 2</p>
</>
);
}
const componentsMap = {
spaDynamicView1: DynamicView1,
spaDynamicView2: DynamicView2,
};
// render
document.querySelectorAll<HTMLElement>('div[data-spa-render]').forEach((e) => {
const key = e.dataset.spaRender;
if (key && componentsMap[key] && !e.dataset.spaRendered) {
ReactDOM.render(componentsMap[key](), e);
e.dataset.spaRendered = 'true';
}
});
AppExample静态组件中包含动态组件占位符:
// src/AppExample.tsx
import React from 'react';
import {
NavLink,
} from 'react-router-dom';
function AppExample(): JSX.Element {
return (
<>
<ul>
<li>
<NavLink to="/home">Home</NavLink>
</li>
<li>
<NavLink to="/app-example">App Example</NavLink>
</li>
</ul>
<p>App Example</p>
<div data-spa-render="spaDynamicView1" />
<div data-spa-render="spaDynamicView2" />
</>
);
}
export default AppExample;
4. 效果体验
主页动态渲染DynamicView2:
AppExample动态渲染DynamicView1 (Counter)、DynamicView2:
5. 结束语
SSR只是为了首屏加载慢与无法进行SEO的问题,我们不能把所有的内容都优先选择SSR。否则,会造成开发效率慢,且难以调试。我们需要根据项目特点与用户体验指标来平衡SSR与前端动态渲染之间的比例,尽可能地进行前后端分离。
SSR只进行一次初始化渲染,useEffect或异步的数据均失效。如果要显示从后端请求的数据,需先获取数据,组件Render之前准备好状态,最后方可调用ReactDOMServer.renderToString()。