Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React服务端渲染与同构实践 #1

Open
HexMox opened this issue Dec 24, 2018 · 0 comments
Open

React服务端渲染与同构实践 #1

HexMox opened this issue Dec 24, 2018 · 0 comments

Comments

@HexMox
Copy link
Owner

HexMox commented Dec 24, 2018

前两年服务端渲染和同构的概念火遍了整个前端界,几乎所有关于前端的分享会议都有提到。在这年头,无论你选择什么技术栈,不会做个服务端渲染可能真的快混不下去了!最近刚好实现了个基于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的做法: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也有说明为什么要这么做:

babel/babel-loader#152

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

后续思考

  • 可以看齐Nextjs

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

  • 发布的不便利性

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

  • 性能问题(TODO)

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

参考资料:

@HexMox HexMox changed the title React同构与服务端渲染实践 React服务端渲染与同构实践 Dec 27, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant