软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

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中添加ssrspa两个入口文件。

仅验证技术的可行性,只完成发布脚本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:

AppExample

5. 结束语

SSR只是为了首屏加载慢与无法进行SEO的问题,我们不能把所有的内容都优先选择SSR。否则,会造成开发效率慢,且难以调试。我们需要根据项目特点与用户体验指标来平衡SSR与前端动态渲染之间的比例,尽可能地进行前后端分离。

SSR只进行一次初始化渲染,useEffect或异步的数据均失效。如果要显示从后端请求的数据,需先获取数据,组件Render之前准备好状态,最后方可调用ReactDOMServer.renderToString()。