React服务端渲染与同构实践

3,635 阅读8分钟

本文作者:IMWeb IMWeb团队 原文出处:IMWeb社区 未经同意,禁止转载

前两年服务端渲染和同构的概念火遍了整个前端界,几乎所有关于前端的分享会议都有提到。在这年头,无论你选择什么技术栈,不会做个服务端渲染可能真的快混不下去了!最近刚好实现了个基于 React&Redux 的同构直出应用,赶紧写个文章总结总结压压惊。

前言

在了解实践过程之前,让我们先明白几个概念(非新手可直接跳过)。

什么是服务端渲染(Server-Side Rendering)

服务端渲染,又可以叫做后端渲染或直出。

早些年前,大部分网站都使用传统的 MVC 架构进行后端渲染,就是实现一个 Controller,处理请求时在服务端拉取到数据 Model,使用模版引擎结合 View 渲染出页面,比如 Java + Velocity、PHP 等。但随着前端脚本 JS 的发展,拥有更强大的交互能力后,前后端分离的概念被提出,也就是拉取数据和渲染的操作由前端来完成。

关于前端渲染还是后端渲染之争,可以看文章后面的参考链接,这里不做讨论。这里照搬后端渲染的优势:

  • 更好的首屏性能,不需要提前先下载一堆 CSS 和 JS 后才看到页面
  • 更利于 SEO,蜘蛛可以直接抓取已渲染的内容

什么是同构应用(Isomorphic)

同构,在本文特指服务端和客户端的同构,意思是服务端和客户端都可以运行的同一套代码程序。

SSR 同构也是在 Node 这门服务端语言兴起后,使得 JS 可以同时运行在服务端和浏览器,使得同构的价值大大提升:

  • 提高代码复用率
  • 提高代码可维护性

基于 React&Redux 的考虑

其实 Vue 和 React 都提供了 SSR 相关的能力,在决定在做之前我们考虑了一下使用哪种技术栈,之所以决定使用 React 是因为对于团队来说,统一技术栈在可维护性上显得比较重要:

  • 已有一套基于 React 的 UI
  • 已有基于 React&Redux 的脚手架
  • 已在 React 直出上有一定的实践经验(仅限于组件同构,Controller 并不通用)

React 提供了一套将 Virtual DOM 输出为 HTML 文本的API

Redux 提供了一套将 reducers 同构复用的解决方案

方案与实践

首先先用脚手架生成了基于 React&Redux 的异步工程目录:

- dist/ # 构建结果
	- xxx.html
	- xxx_[md5].js
	- xxx_[md5].css
- src/ # 源码入口
	- assets/
		- css/ # 全局CSS
		- template.html # 页面模版
	- pages/ # 页面源码目录
		- actions.js # 全局actions
		- reducers.js # 全局reducers
		- xxx/ # 页面名称目录
			- components/ # 页面级别组件
			- index.jsx # 页面主入口
			- reducers.js # 页面reducers
			- actions.js # 页面actions
	- components/ # 全局级别组件
- webpack.config.js
- package.json
- ...

可以看到,现有的异步工程,构建会使用web-webpack-plugin将所有src/pages/xxx/index.js当做入口为每个页面编译出异步 html、js 和 css 文件。

1. 添加 Node Server

既然要做直出,首先需要一个 Web Server 吧,可以使用Koa,这里我们采用了团队自研基于KoaIMServer(作者是开源工具whistle的作者,用过whistle的我表示已经离不开它了),Server 工程目录如下:

- server/
	- app/
		- controller/ # controllers
			- indexReact.js # 通用React直出Controller
		- middleware/ # 中间件
		- router.js   # 路由设置
	- config/
		- config.js # 项目配置
	- lib/ # 内部依赖库
	- dispatch.js # 启动入口
	- package.json
	- ...

由于是一个多页面应用(非 SPA),上文提到之前团队的实践中 Controller 逻辑并不是通用的,也就是说只要业务需求新增一个页面那么就得手写多一个 Controller,而且这些 Controllers 都存在共性逻辑,每个请求过来都要经历:

  1. 根据页面 reducer 创建 store
  2. 拉取首屏数据
  3. 渲染结果
  4. ...(其他自定义钩子)

那我们为什么不实现一个通用的 Controller 将这些逻辑都同构了呢:

// server/app/controller/indexReact.js
const react = require('react');
const { renderToString } = require('react-dom/server');
const { createStore, applyMiddleware } = require('redux');
const thunkMiddleware = require('redux-thunk').default;
const { Provider } = require('react-redux');

async function process(ctx) {
  // 创建store
  const store = createStore(
    reducer /* 1.同构的reducer */,
    undefined,
    applyMiddleware(thunkMiddleware)
  );

  // 拉取首屏数据
  /* 2.同构的component静态方法getPreloadState */
  const preloadedState = await component.getPreloadState(store).then(() => {
    return store.getState();
  });

  // 渲染html
  /* 2.同构的component静态方法getHeadFragment */
  const headEl = component.getHeadFragment(store);
  const contentEl = react.createElement(
    Provider,
    { store },
    react.createElement(component)
  );
  ctx.type = 'html';
  /* 3.基于页面html编译的模版函数template */
  ctx.body = template({
    preloadedState,
    head: renderToString(headEl),
    html: renderToString(contentEl)
  });
}

module.exports = process;

上述代码相当于将处理过程钩子化了,只要同构代码提供相应的钩子即可。

当然,还得根据页面生成相应的路由:

// server/app/router.js
const config = require('../config/config');
const indexReact = require('./controler/indexReact');

module.exports = app => {
  // 需要直出页面路由配置
  const { routes } = config;

  // IMServer会调用此方法,传入koa-router实例
  return router => {
    Object.entries(routes).forEach(([name, v]) => {
      const { pattern } = v;

      router.get(
        name, // 目录名称xxx
        pattern, // 目录路由配置,比如'/course/:id'
        indexReact
      );
    });
  };
};

至此服务端代码已基本完成。

2. 同构构建打通

上一步服务端代码依赖了几份同构代码。

  • 页面数据纯函数 reducer.js
  • 页面组件主入口 component.js
  • 基于web-webpack-plugin生成的页面 xxx.html 再编译的模版函数 template

我选择了通过构建编译出这些文件,而不是在服务端引入babel-register来直接引入前端代码,是因为我想保留更高的自由度,即构建可以做更多babel-register做不了的事情。

// webpack-ssr.config.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const write = require('write');
const webpack = require('webpack');
const FilterPlugin = require('filter-chunk-webpack-plugin');
const { rootDir, serverDir, resolve } = require('./webpack-common.config');
const ssrConf = require('./server/ssr.config');

const { IgnorePlugin } = webpack;

const componentsEntry = {};
const reducersEntry = {};
glob.sync('src/pages/*/').forEach(dirpath => {
  const dirname = path.basename(dirpath);
  const options = { realpath: true };
  componentsEntry[dirname] = glob.sync(
    `${dirpath}/isomorph.{tsx,ts,jsx,js}`,
    options
  )[0];
  reducersEntry[dirname] = glob.sync(
    `${dirpath}/reducers.{tsx,ts,jsx,js}`,
    options
  )[0];
});
const ssrOutputConfig = (o, dirname) => {
  return Object.assign({}, o, {
    path: path.resolve(serverDir, dirname),
    filename: '[name].js',
    libraryTarget: 'commonjs2'
  });
};
const ssrExternals = [/assets\/lib/];
const ssrModuleConfig = {
  rules: [
    {
      test: /\.(css|scss)$/,
      loader: 'ignore-loader'
    },
    {
      test: /\.jsx?$/,
      loader: 'babel-loader?cacheDirectory',
      include: [
        path.resolve(rootDir, 'src'),
        path.resolve(rootDir, 'node_modules/@tencent')
      ]
    },
    {
      test: /\.(gif|png|jpe?g|eot|woff|ttf|pdf)$/,
      loader: 'file-loader'
    }
  ]
};

const ssrPages = Object.entries(ssrConf.routes).map(([pagename]) => {
  return `${pagename}.js`;
});

const ssrPlugins = [
  new IgnorePlugin(/^\.\/locale$/, /moment$/),
  new FilterPlugin({
    select: true,
    patterns: ssrPages
  })
];

const ssrTemplatesDeployer = assets => {
  Object.entries(assets).forEach(([name, asset]) => {
    const { source } = asset;

    // ssr template
    if (/.html$/.test(name)) {
      const content = source()
        // eslint-disable-next-line
        .replace(/(<head[^>]*>)/, '$1${head}')
        .replace(
          /(<\/head>)/,
          // eslint-disable-next-line
          "<script>window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}</script>$1"
        )
        .replace(/(<div[^>]*id="react-body"[^>]*>)/, '$1${html}'); // eslint-disable-line

      write.sync(path.join(serverDir, 'templates', name), content);
    }
  });
};
const devtool = 'source-map';

function getSSRConfigs(options) {
  const { mode, output } = options;

  return [
    {
      mode,
      entry: componentsEntry,
      output: ssrOutputConfig(output, 'components'),
      resolve,
      devtool,
      target: 'node',
      externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    },
    {
      mode,
      entry: reducersEntry,
      output: ssrOutputConfig(output, 'reducers'),
      resolve,
      devtool,
      target: 'node',
      externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    }
  ];
}

module.exports = {
  ssrTemplatesDeployer,
  getSSRConfigs
};

上述代码将 Controller 需要的同构模块和文件打包到了 server/目录下:

src/
	- pages/
		- xxx
			- template.html # 页面模版
			- reducers.js # 页面reducer入口
			- isomorph.jsx # 页面服务端主入口
server/
	- components/
		- xxx.js
	- reducers/
		- xxx.js
	- templates
		- xxx.html # 在Node读取并编译成模版函数即可

webpack-ssr

3. 实现同构钩子

还需要在同构模块中实现通用 Controller 约定。

// src/pages/xxx/isomorph.tsx
import * as React from 'react';
import { bindActionCreators, Store } from 'redux';
import * as actions from './actions';
import { AppState } from './reducers';
import Container, { getCourceId } from './components/Container';

Object.assign(Container, {
  getPreloadState(store: Store<AppState>) {
    type ActionCreatorsMap = {
      fetchCourseInfo: (x: actions.CourseInfoParams) => Promise<any>;
    };

    const cid = getCourceId();
    const { fetchCourseInfo } = bindActionCreators<{}, ActionCreatorsMap>(actions, store.dispatch);

    return fetchCourseInfo({ course_id: cid })
  },

  getHeadFragment(store: Store<AppState>) {
    const cid = getCourceId();
    const { courseInfo } = store.getState();
    const { name, summary, agency_name: agencyName } = courseInfo.data;
    const keywords = ['腾讯课堂', name, agencyName].join(',');
    const canonical = `//ke.qq.com/course/${cid}`;

    return (
      <>
        <title>{name}</title>
        <meta name="keywords" content={keywords} />
        <meta name="description" itemProp="description" content={summary} />
        <link rel="canonical" href={canonical} />
      </>
    );
  },
});

export default Container;

至此同构已基本打通。

4. 异步入口&容灾

剩下来就好办了,在异步 JS 入口中使用ReactDOM.hydrate

// src/pages/xxx/index.tsx
import * as React from 'react';
import { hydrate } from 'react-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { Provider } from 'react-redux';
import reducers from './reducers';
import Container from './components/Container';
import './index.css';

let store;
const preloadState = window.__PRELOADED_STATE__;

if (process.env.NODE_ENV === 'production') {
  store = createStore(reducers, preloadState, applyMiddleware(thunkMiddleware));
} else {
  store = createStore(
    reducers,
    preloadState,
    compose(
      applyMiddleware(thunkMiddleware),
      window.devToolsExtension ? window.devToolsExtension() : (f: any) => f
    )
  );
}

hydrate(
  <Provider store={store}>
    <Container />
  </Provider>,
  window.document.getElementById('react-body')
);

hydrate() Same as render(), but is used to hydrate a container whose HTML contents were rendered by ReactDOMServer. React will attempt to attach event listeners to the existing markup.

React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them.

容灾是指当服务端因为某些原因挂掉的时候,由于我们还有构建生成 xxx.html 异步页面,可以在 nginx 层上做一个容灾方案,当上层 Svr 出现错误时,降级异步页面。

踩坑

  • 无法同构的业务逻辑

像因为生命周期的不同要在componentDidMount绑定事件,不能在服务端能执行到的地方访问 DOM API 这些大家都应该很清楚了,其实大概只需要实现最主要几个同构的基础模块即可:

  1. 访问 location 模块
  2. 访问 cookie 模块
  3. 访问 userAgent 模块
  4. request 请求模块
  5. localStorage、window.name 这种只能降级处理的模块(尽量避免在首屏逻辑使用到它们)

当然我要说的还有一些依赖客户端能力的模块,比如 wx 的 sdk,qq 的 sdk 等等。

这里稍微要提一下的是,我最初设计的时候想尽可能不破坏团队现有的编码习惯,像 location、cookie 之类的这些模块方法在每次请求过来的时候,拿到的值应该是不一样的,如何实现这一点是参考 TSW 的做法:tswjs.org/doc/api/glo…,Node 的domain 模块使得这类设计成为可能。

但是依旧要避免模块局部变量的写法(有关这部分内容,我另写了一篇文章可做参考

  • 使用ignore-loader忽略掉依赖的 css 文件
  • core-js包导致内存泄漏
    {
      test: /\.jsx?$/,
      loader: 'babel-loader?cacheDirectory',
      // 干掉babel-runtime,其依赖core-js源码对global['__core-js_shared__']操作引起内存泄漏
      options: {
        babelrc: false,
        presets: [
          ['env', {
            targets: {
              node: true
            }
          }],
          'stage-2',
          'react'
        ],
        plugins: ['syntax-dynamic-import']
      },
      include: [
        path.resolve(rootDir, 'src'),
        path.resolve(rootDir, 'node_modules/@tencent')
      ]
    }

这部分 core-js 的上的 issue 也有说明为什么要这么做:

github.com/babel/babel…

其实在 node 上 es6 的特性是都支持了的,打包出的同构模块需要尽可能的精简。

后续思考

  • 可以看齐 Nextjs

这整个设计其实把构建能力抽象出来,钩子可配置化后,就可以成为一个直出框架了。当然也可以像 Nextjs 那样实现一些 Document 等组件来使用。

  • 发布的不便利性

当前设计由于 Server 的代码依赖了构建出来的同构模块,在日常开发中,前端做一些页面修改是经常发生的事,比如修改一些事件监听,而这时候因为 js, css 资源 MD5 值的变化,导致 template.html 变化,故而导致 server 包需要发布,如果业务有有多节点,都要一一无损重启。肯定是有办法做到发布代码而不用重启 Node 服务的。

  • 性能问题(TODO)

以上就是本文的所有内容,请多多指教,欢迎交流(文中代码基本都是经过删减的)~

参考资料: