单页面应用(SPA)模式被愈来愈多的站点所采用,这种模式意味着使用 JavaScript 直接在浏览器中渲染页面。全部逻辑,数据获取,模板和路由都在客户端处理,势必面临着首次有效绘制(FMP)耗时较长和不利于搜索引擎优化(SEO)的问题。css
“同构(Universal)” 是指一套代码能够在服务端和客户端两种环境下运行,经过用这种灵活性,能够在服务端渲染初始内容输出到页面,后续工做交给客户端来完成,最终来解决SEO的问题并提高性能。“同构应用” 就像是精灵,能够游刃有余的穿梭在服务端与客户端之间各尽其能。html
可是想驾驭 “同构应用” 每每会面临一系列的问题,下面针对一个示例进行一些细节介绍。前端
示例代码: https://github.com/xyyjk/reac...
选择一个灵活的脚手架为项目后续的自定义功能及配置是十分有利的,Neutrino 提供了一些经常使用的 Webpack 预设配置,这些预设中包含了开发过程当中常见的一些插件及配置使初始化和构建项目的过程更加简单。node
下面基于预设作一些自定义配置,你能够随时经过运行 node_modules/.bin/neutrino --inspect
来了解最终完整的 Webpack 配置。react
这里基于 @neutrinojs/react 预设作一些定义用于开发webpack
.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(客户端渲染) 任意一种渲染模式,经过定义变量 isDev
、 isSSR
用以作差别配置:git
devServer.before
方法能够在服务内部的全部其余中间件以前,提供执行自定义中间件的功能。github
在 SSR 模式
下加入一个中间件,稍后用于进行处理服务端组件内容渲染,同时很好的利用到了 devServer.hot
热更新功能。web
在 SSR 模式
下使用动态定义 html
模板(src/server/template.js
),这里把底层使用的 html-webpack-plugin
去掉。npm
启用 manifest
插件,打包后生成资源映射文件用于服务端渲染时模板中引入。
构建用于服务端运行的配置项稍有不一样,因为 SSR 模式
最终代码要运行在 node 环境,这里须要对配置再作一些调整:
target
调整为 node
,编译为类 Node 环境可用libraryTarget
调整为 commonjs2
,使用 Node 风格导出模块@babel/preset-env
运行环境调整为 node
,编译结果为 ES6 代码css/sass
资源的引用,生产环境直接使用经过 manifest
插件构建出的映射文件来读取资源在打包的时候经过 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 对象来拿到一些信息:
${req.protocol}://${req.headers.host}${req.url}
req.headers.cookie
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()
方法把服务端返回的静态 HTML 与事件相融合绑定。
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、Electrode 等,若是你想快速入手 React SSR
这些或许是不错的选择。若是面对复杂应用,自定义完整的体系将会更加灵活。