单页面应用(SPA)模式被越来越多的站点所采用,这种模式势必面临着首次有效绘制(FMP)耗时较长和不利于搜索引擎优化(SEO)的问题。“同构应用”就像是精灵,可以游刃有余的穿梭在服务端与客户端之间各尽其能。
但是想驾驭“同构应用”往往会面临一系列的问题,下面针对一个示例进行一些细节介绍。
示例代码:https://github.com/xyyjk/reac…“同构”是指一套代码可以在服务端和客户端两种环境下运行,通过用这种灵活性,可以在服务端渲染初始内容输出到页面,后续工作交给客户端来完成,最终来解决 SEO 的问题并提升性能。
构建配置
选择一个灵活的脚手架为项目后续的自定义功能及配置是十分有利的,Neutrino 提供了一些常用的 webpack 预设配置,使初始化和构建项目的过程更加简单。
这里基于 @neutrinojs/react 预设做一些定义用于开发
.neutrinorc.js
const isDev = process.env.NODE_ENV !== ‘production’;
const isSSR = process.argv.includes(‘–ssr’);
module.exports = {
use: [
[‘@neutrinojs/react’, {
devServer: {
port: isSSR ? 3000 : 5000,
host: ‘0.0.0.0’,
disableHostCheck: true,
contentBase: `${__dirname}/src`,
before(app) {if(isSSR) {require(‘./src/server’)(app); } },
},
manifest: true,
html: isSSR ? false: {},
clean: {paths: [‘./node_modules/.cache’]},
}],
({config}) => {
if (isDev) {return;}
config
.output
.filename(‘assets/[name].[chunkhash].js’)
.chunkFilename(‘assets/chunk.[chunkhash].js’)
.end()
.optimization
.minimize(false)
.end();
},
],
};
为了达到开发环境下可以选择 SSR(服务端渲染)、CSR(客户端渲染) 任意一种渲染模式,在开始先定义一个变量 isSSR 用以做差异配置:
devServer.before 方法可以在服务内部的所有其他中间件之前,提供执行自定义中间件的功能。
在 SSR 模式 下加入一个中间件,稍后用于进行处理服务端内容渲染。
启用 manifest 插件,打包后生成资源映射文件用于服务端渲染时模板中引入。
构建用于服务端运行的配置项稍有不同
由于 SSR 模式 最终代码要运行在 node 环境,这里需要对配置再做一些调整:
target 调整为 node,编译为类 Node 环境可用
libraryTarget 调整为 commonjs2,使用 Node 风格导出模块
preset-env 运行环境调整为 node
排除组件中 css/sass 资源的引用
在打包的时候通过 webpack-node-externals 排除 node_modules 依赖模块,可以使服务器构建速度更快,并生成较小的 bundle 文件。
webpack.server.config.js
const Neutrino = require(‘neutrino/Neutrino’);
const nodeExternals = require(‘webpack-node-externals’);
const NormalPlugin = require(‘webpack/lib/NormalModuleReplacementPlugin’);
const babelMerge = require(‘babel-merge’);
const config = require(‘./.neutrinorc’);
const neutrino = new Neutrino();
neutrino.use(config);
neutrino.config
.target(‘node’)
.entryPoints
.delete(‘index’)
.end()
.entry(‘server’)
.add(`${__dirname}/src/server`)
.end()
.output
.path(`${__dirname}/build`)
.filename(‘server.js’)
.libraryTarget(‘commonjs2’)
.end()
.externals([nodeExternals()])
.plugins
.delete(‘clean’)
.delete(‘manifest’)
.end()
.plugin(‘normal’)
.use(NormalPlugin, [/\.css$/, ‘lodash/noop’])
.end()
.optimization
.minimize(false)
.runtimeChunk(false)
.end()
.module
.rule(‘compile’)
.use(‘babel’)
.tap(options => babelMerge(options, {
presets: [
[‘@babel/preset-env’, {
targets: {node: true},
}],
],
}));
module.exports = neutrino.config.toConfig();
环境差异
由于运行环境和平台 API 的差异,当运行在不同环境中时,我们的代码将不会完全相同。
Webpack 全局对象中定义了 process.browser,可以在开发环境中来判断当前是客户端还是服务端。
自定义中间件
开发环境 SSR 模式 下,如果我们在组件中引入了图片或样式资源,不经过 webpack-loader 进行编译,Node 环境下是无法直接运行的。在 Node 环境下,通过 ignore-styles 可以把这些资源进行忽略。
此外,为了让 Node 环境下能够运行 ES6 模块的组件,需要引入 @babel/register 来做一些转换:
src/server/register.js
require(‘ignore-styles’);
require(‘@babel/register’)({
presets: [
[‘@babel/preset-env’, {
targets: {node: true},
}],
‘@babel/preset-react’,
],
plugins: [
‘@babel/plugin-proposal-class-properties’,
],
});
如果 webpack 中配置了 resolve.alias,与之对应的还需要增加 babel-plugin-module-resolver 插件来做解析。
由于 require() 引入方式模块将会被缓存,为了使组件内的修改实时生效,通过 decache 模块从 require() 缓存中删除模块:
src/server/dev.js
require(‘./register’);
const decache = require(‘decache’);
const routes = require(‘./routes’);
let render = require(‘./render’);
const handler = async (req, res, next) => {
decache(‘./render’);
render = require(‘./render’);
res.send(await render({ req, res}));
next();
};
module.exports = (app) => {
app.get(routes, handler);
};
服务端渲染
在服务端通过 ReactDOMServer.renderToString() 方法将组件渲染为初始 HTML 字符串。
获取数据往往需要从 query、cookie 中取一些内容作为接口参数,Node 环境下没有 window、document 这样的浏览器对象,可以借助 Express 的 req 对象来拿到一些信息:
href: ${req.protocol}://${req.headers.host}${req.url}
cookie: req.headers.cookie
userAgent: req.headers[‘user-agent’]
src/server/render.js
const React = require(‘react’);
const {renderToString} = require(‘react-dom/server’);
…
module.exports = async ({req, res}) => {
const locals = {
data: await fetchData({req, res}),
href: `${req.protocol}://${req.headers.host}${req.url}`,
url: req.url,
};
const markup = renderToString(<App locals={locals} />);
const helmet = Helmet.renderStatic();
return template({markup, helmet, assets, locals});
};
入口文件
前端调用 ReactDOM.hydrate() 方法与服务端返回的静态标记相绑定事件。
src/index.jsx
import React from ‘react’;
import ReactDOM from ‘react-dom’;
import App from ‘./App’;
const renderMethod = ReactDOM[module.hot ? ‘render’ : ‘hydrate’];
renderMethod(<App />, document.getElementById(‘root’));
根组件
在服务端使用 StaticRouter 组件,通过 location 属性设置服务器收到的 URL,并在 context 属性中存入渲染期间所需要的数据。
src/App.jsx
import React from ‘react’;
import {BrowserRouter, StaticRouter, Route} from ‘react-router-dom’;
import {hot} from ‘react-hot-loader/root’;
…
const Router = process.browser ? BrowserRouter : StaticRouter;
const App = ({locals = {} }) => (
<Router location={locals.url} context={locals}>
<Layout>
<Route exact path=”/” component={Home}/>
<Route path=”/about” component={About}/>
<Route path=”/contact” component={Contact}/>
<Route path=”/character/:key” component={Character}/>
</Layout>
</Router>
);
export default hot(App);
内容数据
通过 constructor 接收 StaticRouter 组件传入的数据,客户端 URL 与服务端请求地址相一致时直接使用传入的数据,否则再进行客户端数据请求。
src/comps/Content.jsx
import React from ‘react’;
import {withRouter} from ‘react-router-dom’;
import fetchData from ‘../utils/fetchData’;
function isCurUrl() {
if (!window.__INITIAL_DATA__) {return false;}
return document.location.href === window.__INITIAL_DATA__.href;
}
class Content extends React.Component {
constructor(props) {
super(props);
const {staticContext = {} } = props;
let {data = {} } = staticContext;
if (process.browser && isCurUrl()) {
data = window.__INITIAL_DATA__.data;
}
this.state = {data};
}
async componentDidMount() {
if (isCurUrl()) {return;}
const {match} = this.props;
const data = await fetchData({match});
this.setState({data});
}
render() {
return this.props.render(this.state);
}
}
export default withRouter(Content);
自定义标记
通常在不同页面中需要输出不同的页面标题、页面描述,HTML 属性等,可以借助 react-helmet 来处理此类问题:
模板设置
const markup = ReactDOMServer.renderToString(<Handler />);
const helmet = Helmet.renderStatic();
const template = `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
<meta charset=”UTF-8″>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
</head>
<body ${helmet.bodyAttributes.toString()}>
<div id=”root”>${markup}</div>
</body>
</html>
`;
组件中的使用
import React from ‘react’;
import Helmet from ‘react-helmet’;
const Contact = () => (
<>
<h2>This is the contact page</h2>
<Helmet>
<title>Contact Page</title>
<meta name=”description” content=”This is a proof of concept for React SSR” />
</Helmet>
</>
);
总结
想要做好“同构应用”并不简单,需要了解非常多的概念。好消息是目前 React 社区有一些比较著名的同构方案 Next.js、Razzle 等,如果你想快速入手 React SSR 这些或许是不错的选择。如果面对复杂应用,自定义完整的体系将会更加灵活。