12

单页面应用(SPA)模式被越来越多的站点所采用,这种模式意味着使用 JavaScript 直接在浏览器中渲染页面。所有逻辑,数据获取,模板和路由都在客户端处理,势必面临着首次有效绘制(FMP)耗时较长和不利于搜索引擎优化(SEO)的问题。

“同构(Universal)” 是指一套代码可以在服务端和客户端两种环境下运行,通过用这种灵活性,可以在服务端渲染初始内容输出到页面,后续工作交给客户端来完成,最终来解决SEO的问题并提升性能。“同构应用” 就像是精灵,可以游刃有余的穿梭在服务端与客户端之间各尽其能。

但是想驾驭 “同构应用” 往往会面临一系列的问题,下面针对一个示例进行一些细节介绍。

示例代码:https://github.com/xyyjk/reac...

构建配置

选择一个灵活的脚手架为项目后续的自定义功能及配置是十分有利的,Neutrino 提供了一些常用的 Webpack 预设配置,这些预设中包含了开发过程中常见的一些插件及配置使初始化和构建项目的过程更加简单。

下面基于预设做一些自定义配置,你可以随时通过运行 node_modules/.bin/neutrino --inspect 来了解最终完整的 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(客户端渲染) 任意一种渲染模式,通过定义变量 isDevisSSR 用以做差异配置:

devServer.before 方法可以在服务内部的所有其他中间件之前,提供执行自定义中间件的功能。

SSR 模式 下加入一个中间件,稍后用于进行处理服务端组件内容渲染,同时很好的利用到了 devServer.hot 热更新功能。

SSR 模式 下使用动态定义 html 模板(src/server/template.js),这里把底层使用的 html-webpack-plugin 去掉。

启用 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 字符串。

获取数据往往需要从 querycookie 中取一些内容作为接口参数,
Node 环境下没有 windowdocument 这样的浏览器对象,可以借助 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() 方法把服务端返回的静态 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.jsRazzleElectrode 等,如果你想快速入手 React SSR 这些或许是不错的选择。如果面对复杂应用,自定义完整的体系将会更加灵活。


xyyjk
360 声望7 粉丝