React Koa Rematch 服务端渲染架构实战:从原理到避坑指南

作者:北岛时间:2026-10-23 09:00:19浏览:447

本文详解基于 React、Koa 和 Rematch 构建服务端渲染(SSR)应用的全流程。内容涵盖 SSR 优势、组件与路由同步渲染机制、Redux 同构(注水/脱水)实现,以及 CSS 直出方案。重点解析 StaticRouter 替换、Promise 数组处理异步数据及 isomorphic-style-loader 配置,并总结异步组件与 CSS 提取插件的使用限制与避坑经验。

React SSR 架构概述

前言

本文主要介绍如何利用 React 与 Koa 搭建一套服务端渲染(SSR)框架。该方案基于现有开源库进行二次开发,融入了个人对功能完善的思考与实践。

本方案涉及的核心技术栈包括:React、Rematch、react-router 以及 Koa。

服务端渲染优势分析

react服务端渲染优势

单页应用(SPA)虽然在交互体验上优于传统多页应用,但存在对搜索引擎不友好的先天缺陷,不利于爬虫抓取数据(尽管 Chrome 已支持异步抓取 SPA 页面数据)。

与传统 SPA 相比,服务端渲染(SSR)的主要优势在于提升 SEO 效果和优化首屏加载速度。

SPA 初始化时仅渲染一个空的 div,必须等待 JavaScript 下载完成后才开始页面渲染;而 SSR 可直接渲染 HTML 结构,极大优化了首屏加载时间。然而,这种做法显著增加了开发成本,因此开发者需权衡首屏时间的重要性,必要时可考虑使用骨架屏等替代方案。

组件与路由同步渲染

react服务端渲染流程

组件渲染

渲染过程始于根组件的 render 方法,这部分与 SPA 存在细微差异。

使用 ReactDOM.render() 混合服务端渲染容器的方法已被弃用,并将在 React 17 中移除。应改用 hydrate() 方法。

hydrate 与 render 功能相似,但专门用于混合由 ReactDOMServer 渲染的容器。React 会尝试将事件监听器附加到现有的 DOM 标记上。

hydrate 描述了 ReactDOM 复用 ReactDOMServer 服务端渲染内容时,尽可能保留原有结构并补充客户端特有内容(如事件绑定)的过程。


import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.hydrate(<App />, document.getElementById('app'));

在服务端,我们可以通过 renderToString 获取渲染内容,从而替换 HTML 模板中的占位符。


const jsx = 
  <StaticRouter location={url} context={routerContext}>
    <AppRoutes context={defaultContext} initialData={data} />
  </StaticRouter>
  
const html = ReactDOMServer.renderToString(jsx);

let ret = `
  <!DOCTYPE html>
    <html lang="en">
    <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
    </head>
    <body>
     <div id="app">${html}</div>
    </body>
  </html>
`;

return ret;

服务端返回替换后的 HTML,即完成了组件的服务端渲染过程。

路由同步渲染

项目中不可避免需要使用路由,而在 SSR 中,必须实现路由的同步渲染。

我们可以将路由拆分为一个独立组件,供服务端入口和客户端分别引用。


function AppRoutes({ context, initialData }: any) {
 return (
  <Switch>
   {
    routes.map((d: any) => (
     <Route<InitRoute>
      key={d.path}
      exact={d.exact}
      path={d.path}
      init={d.init || ''}
      component={d.component}
     />
    ))
   }
   <Route path='/' component={Home} />
  </Switch>
 );
}

(routes.js)


export const routes = [
 {
  path: '/Home',
  component: Home,
  init: Home.init,
  exact: true,
 },
 {
  path: '/Hello',
  component: Hello,
  init: Hello.init,
  exact: true,
 }
];

这样路由的基本定义便完成了。客户端的引用方式与 SPA 无异,遵循老规矩。


import { BrowserRouter as Router } from 'react-router-dom';
import AppRoutes from './AppRoutes';
class App extends Component<any, Readonly<State>> {
...
 render() {
  return (
  <Router>
   <AppRoutes/>
  </Router>
  );
 }
}

在服务端,需要将 BrowserRouter 替换为 StaticRouter。两者的区别在于:BrowserRouter 通过 HTML5 history API 保持页面与 URL 同步,而 StaticRouter 不改变 URL,当匹配路由时,会将 context 对象传递给渲染为 staticContext 的组件。


const jsx = 
  <StaticRouter location={url}>
    <AppRoutes />
  </StaticRouter>
  
const html = ReactDOMServer.renderToString(jsx);

至此,路由的同步渲染功能已完成。

Redux 同构实现

redux同构

在实现此部分前,需理解“注水”与“脱水”的概念。所谓脱水,是指服务器在构建 HTML 之前处理预请求,并将数据注入 HTML 返回给浏览器;注水则是浏览器将这些数据作为初始数据来初始化组件,从而实现服务端与浏览器端数据的统一。

组件配置

在组件内部定义一个静态方法。


class Home extends React.Component {
...
 public static init(store:any) {
  return store.dispatch.Home.incrementAsync(5);
 }
 componentDidMount() {
  const { incrementAsync }:any = this.props;
  incrementAsync(5);
 }
 render() {
 ...
 }
}

const mapStateToProps = (state:any) => {
 return {
  count: state.Home.count
 };
};

const mapDispatchToProps = (dispatch:any) => ({
 incrementAsync: dispatch.Home.incrementAsync
});
export default connect(
 mapStateToProps,
 mapDispatchToProps
)(Home);

由于本项目使用 Rematch,因此相关方法均编写在 model 中。


const Home: ModelConfig= {
 state: {
  count: 1
 }, 
 reducers: {
  increment(state, payload) {
   return {
    count: payload
   };
  }
 },
 effects: {
  async incrementAsync(payload, rootState) {
   await new Promise((resolve) => setTimeout(resolve, 1000));
   this.increment(payload);
  }
 }
};
export default Home;

随后通过根 store 进行初始化(init)。


import { init } from '@rematch/core';
import models from './models';

const store = init({
 models: {...models}
});

export default store;

接着可以将数据绑定到 Redux 的 Provider 中。


<Provider store = {store}>
  <Router>
   <AppRoutes
    context={context}
    initialData={this.initialData}
   />
  </Router>
</Provider>

在路由方面,需将组件的 init 方法绑定到路由上,以便服务端请求数据时使用。


<Switch>
   {
    routes.map((d: any) => (
     <Route<InitRoute>
      key={d.path}
      exact={d.exact}
      path={d.path}
      init={d.init || ''}
      component={d.component}
     />
    ))
   }
   <Route path='/' component={Home} />
  </Switch>

以上为客户端需执行的操作。鉴于 SSR 中服务端也需进行数据操作,为实现解耦,我们新建另一个 ServiceStore 来专门服务于服务端。

在服务端构建 HTML 之前,必须执行完当前组件的 init 方法。


import { matchRoutes } from 'react-router-config';
// 用matchRoutes方法获取匹配到的路由对应的组件数组
const matchedRoutes = matchRoutes(routes, url);
const promises = [];
for (const item of matchedRoutes) {
 if (item.route.init) {
  const promise = new Promise((resolve, reject) => {
   item.route.init(serverStore).then(resolve).catch(resolve);
  });
  promises.push(promise);
 }
}
return Promise.all(promises);

注意,我们新建一个 Promise 数组来存放 init 方法。由于一个页面可能由多个组件组成,必须等待所有 init 方法执行完毕后,再执行相应的 HTML 构建。

此时获取到的数据可挂载到 window 对象下,等待客户端读取。


let ret = `
   <!DOCTYPE html>
    <html lang="en">
    <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
    </head>
    <body>
     <div id="app">${html}</div>
     <script type="text/javascript">window.__INITIAL_STORE__ = ${JSON.stringify(
      extra.initialStore || {}
     )}</script>
    </body>
   </html>
  `;

随后在客户端中读取刚才的 initialStore 数据。


....
const defaultStore = window.__INITIAL_STORE__ || {};
const store = init({
 models,
 redux: {
  initialState: defaultStore
 }
});

export default store;

至此,Redux 的同构基本完成。受篇幅限制,此处未展示过多代码,具体实现可访问文章底部的仓库链接查看。接下来将介绍 Redux 同构中几个常见的坑。

1. 无法使用 @loadable/component 异步组件加载,因为无法获取组件内部方法。解决办法是在预请求阶段不将逻辑放在组件中,而是拆分出来在单独文件中统一管理,但因觉得这样不利于管理,最终放弃了异步加载组件的方案。

2. 在客户端渲染时如果数据出现一闪而过的现象,说明初始化数据并未成功加载。这一点曾困扰许久。

CSS 样式直出方案

css样式直出

首先,服务端渲染时解析 CSS 文件不能使用 style-loader,而应使用 isomorphic-style-loader。使用 style-loader 会导致页面出现样式闪烁现象,因为浏览器需加载完 CSS 才能应用样式。为解决此问题,可通过 isomorphic-style-loader 在组件加载时将 CSS 放置到全局 context 中,然后在服务端渲染时提取出来,插入到返回的 HTML 的 style 标签中。

组件的改造


import withStyles from 'isomorphic-style-loader/withStyles';

@withStyles(style)
class Home extends React.Component {
...
 render() {
  const {count}:any = this.props;
  return (
  ...
  );
 }
}
const mapStateToProps = (state:any) => {
 return {
  count: state.Home.count
 };
};

const mapDispatchToProps = (dispatch:any) => ({
 incrementAsync: dispatch.Home.incrementAsync
});
export default connect(
 mapStateToProps,
 mapDispatchToProps
)(Home);

withStyle 是一个柯里化函数,返回新组件,不影响 connect 函数,当然也可以采用与 connect 相同的写法。withStyle 主要用于将样式插入到全局 context 中。

根组件的修改

import StyleContext from 'isomorphic-style-loader/StyleContext';


const insertCss = (...styles:any) => {
 const removeCss = styles.map((style:any) => style._insertCss());
 return () => removeCss.forEach((dispose:any) => dispose());
};

ReactDOM.hydrate(
  <StyleContext.Provider value={{ insertCss }}>
    <AppError>
     <Component />
    </AppError>
  </StyleContext.Provider>,
  elRoot
);

这部分主要引入 StyleContext 初始化根部的 context,并定义好一个 insertCss 方法,在组件 withStyle 中触发。

部分 isomorphic-style-loader 源码


...
function WithStyles(props, context) {
  var _this;
  _this = _React$PureComponent.call(this, props, context) || this;
  _this.removeCss = context.insertCss.apply(context, styles);
  return _this;
 }

 var _proto = WithStyles.prototype;

 _proto.componentWillUnmount = function componentWillUnmount() {
  if (this.removeCss) {
   setTimeout(this.removeCss, 0);
  }
 };

 _proto.render = function render() {
  return React.createElement(ComposedComponent, this.props);
 };
 ...

可以看到 context 中的 insert 方法即为根组件中定义好的 insert 方法,并在 componentWillUnmount 生命周期中清除之前的样式。insert 方法主要作用是为当前样式定义 ID 并嵌入,此处不再展开,有兴趣者可查阅源码。

服务端中获取定义好的css


const css = new Set(); // CSS for all rendered React components

const insertCss = (...styles :any) => {
 return styles.forEach((style:any) => css.add(style._getCss()));
};

const extractor = new ChunkExtractor({ statsFile: this.statsFile });

const jsx = extractor.collectChunks(
 <StyleContext.Provider value={{ insertCss }}>
  <Provider store={serverStore}>
    <StaticRouter location={url} context={routerContext}>
     <AppRoutes context={defaultContext} initialData={data} />
    </StaticRouter>
  </Provider>
 </StyleContext.Provider>
);

const html = ReactDOMServer.renderToString(jsx);
const cssString = Array.from(css).join('');
...

其中 cssString 即为我们最终获取到的 CSS 内容,可像替换 HTML 一样将 CSS 嵌入到 HTML 中。


let ret = `
   <!DOCTYPE html>
    <html lang="en">
    <head>
     ...
     <style>${extra.cssString}</style>
    </head>
    <body>
     <div id="app">${html}</div>
     ...
    </body>
   </html>
  `;

至此,大功告成!

常见问题与避坑指南

接下来分享在开发过程中遇到的坑。

1. 不能使用分离 CSS 的插件 mini-css-extract-plugin,因为分离 CSS 与将 CSS 放置到 style 标签中存在冲突。引用 GitHub 上大神的一段话:

With isomorphic-style-loader the idea was to always include css into js files but render into dom only critical css and also make this solution universal (works the same on client and server side). If you want to extract css into separate files you probably need to find another way how to generate critical css rather than use isomorphic-style-loader.

2. 许多文章提到在服务端打包中无需打包 CSS,那是因为他们使用的是 style-loader。若使用 isomorphic-style-loader,则仍需打包 CSS,因为服务端中毕竟要触发 withStyle。

总结

由于代码量较大,此处仅展示了整个 SSR 流程的核心思想,详细代码请参阅相关链接。同时也希望各位大牛能指出文中的错误,万分感谢!

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章