React服务端渲染与同构实践

本文作者: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读取并编译成模版函数即可

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 的做法:https://tswjs.org/doc/api/global,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 也有说明为什么要这么做:

https://github.com/babel/babel-loader/issues/152

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

后续思考

  • 可以看齐 Nextjs

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

  • 发布的不便利性

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

  • 性能问题(TODO)

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

参考资料:

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏前端迷

喜马拉雅、ctrip、b站、流利说、蜻蜓FM、爱回收前端面试经历

我的回答是[1,2,6,4,3,5]。这道题目主要考对JS宏任务和微任务的理解程度,JS的事件循环中每个宏任务称为一个Tick(标记),在每个标记的末尾会追加一...

14220
来自专栏石头岛

NIO概述

服务器实现模式为一个请求一个线程,但客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。

8030
来自专栏京程一灯

JavaScript 框架安全报告2019[每日前端夜话0xE5]

在此报告中,我们调查了 Angular 和 React 生态系统的安全状态。在这份报告种我们根本没有将它们作为竞争性框架进行比较。相反,我们把它们作为可行的构建...

7310
来自专栏Java3y

外行人都能看懂的WebFlux,错过了血亏

如果有关注我公众号文章的同学就会发现,最近我不定时转发了一些比较好的WebFlux的文章,因为我最近在学。

9330
来自专栏京程一灯

JavaScript 测试教程 part 1:用 Jest 进行单元测试[每日前端夜话0xE7]

有多种不同种类的测试,我会首先解释其中的一部分。首先,我将介绍单元测试的基础知识,即测试应用程序的每个部分并检查它们是否适合使用。为此我们将使用 Faceboo...

8920
来自专栏后端技术漫谈

外行人都能看懂的WebFlux,错过了血亏

如果有关注我公众号文章的同学就会发现,最近我不定时转发了一些比较好的WebFlux的文章,因为我最近在学。

8210
来自专栏QQ音乐前端团队专栏

React Hooks 源码解析(2): 组件逻辑复用与扩展

React 源码版本: v16.9.0 源码注释笔记:airingursb/react 如何复用和扩展 React 组件的状态逻辑?具体而言,有以下五种方案:...

6110
来自专栏前端桃园

前端测试体系建设与最佳实践总结

2019 年前端测试依然是一个炙手可热的话题。笔者在今年 5 月份参加 Vueconf 的时候,Vue 单元测试的主题演讲者曾向现场的参与者发出提问,有多少团队...

9530
来自专栏芋道源码1024

为什么 Redis 单线程能支撑高并发?

最近在看 UNIX 网络编程并研究了一下 Redis 的实现,感觉 Redis 的源代码十分适合阅读和分析,其中 I/O 多路复用(mutiplexing)部分...

6700
来自专栏前端迷

JavaScript开发重型跨平台应用以及架构

不知不觉,九月就要过去,由于这个月工作上,被C++折磨得很难受,而且其他时间都在学习,所以没有时间写文章,好在技术提升很大。今天准备好好谈一谈重型应用的架构以及...

6710

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励