cover-image 最近把重邮镜像源的主页进行了 React 同构化改造, 这里把遇到的问题和解决方案分享一下.

我在 github 上开源了这个 boilerplate, 其实业内对同构应用早有套路, Github 上已经有很多同构渲染的 demo, 我也参考了其中一些的实现和设计思路

同构的优势

  1. 首屏性能
  2. SEO / 搜索引擎爬虫支持
  3. 无缝的用户体验

实现的细节

目录结构

.
├── app
│   ├── actions
│   ├── common
│   ├── components
│   ├── containers
│   │   └── App.jsx             # React App
│   ├── reducers
│   ├── routes.js               # 路由配置文件
│   └── store
│       └── configureStore.js
├── bin
│   ├── development.js
│   └── production.js
├── package.json
├── platforms
│   ├── browser                 # 浏览器相关
│   │   └── index.js            # 浏览器 APP 入口
│   ├── common
│   │   └── config              # 配置
│   │       ├── default.js
│   │       └── index.js
│   └── server                  # 服务端相关
│       ├── controllers
│       │   ├── indexCtrl.js
│       │   ├── serverRenderCtrl.js
│       │   └── usersCtrl.js
│       ├── index.js            # 服务端入口
│       ├── middlewareRegister.js
│       ├── models
│       ├── routes              # 服务端路由
│       │   ├── api.js
│       │   ├── index.js
│       │   └── render.js
│       ├── services
│       └── templates           # 服务端模板
│           ├── 404.ejs
│           ├── 422.ejs
│           ├── 500.ejs
│           └── index.ejs
├── pm2.json
├── public                      # public
│   ├── favicon.ico
│   └── robots.txt
├── test
│   └── test.js
├── webpack.build.js
└── webpack.development.js

由于在同构应用中, 有三种代码: 客户端 only ,服务端 only , 共用代码 , 这使得良好的目录结构显得更加重要. 目录结构中对各端代码进行了区分, 对于 react 的目录采取了广泛使用的 redux 实现, components 的设计采用的是现代化的一目录一组件的形式. 目录的结构我修改了多次, 对于目前的结构我也觉得有不合理的地方, 欢迎提 issue 讨论.

ServerSideRender

有人提出 React 是一种架构模式, 无论是内建的 DOM、Native还是React Canvas都是的一种基于React模式的具体实现. 那么 HTML string 其实也是 react 模式的一种实现, 只不过产出的是一堆字符串. React 在前几个版本中就把 render 函数从 react 中单独独立出来(react-dom). 具体来说, 服务端渲染依赖的是 react-dom/server 中的 renderToString 方法.

import { RouterContext } from 'react-router'  
import { renderToString } from 'react-dom/server'

const appHtml = renderToString(<App {...renderProps} />)  

路由

由于服务端需要做这么几件事情:

  1. react render
  2. api server

这些都需要路由来做分发

由于 React 其生态圈依赖性大, 也就是说, 要达到最舒心的体验, 你最好全部采取 react 生态圈中提供的解决方案来做. 这也是采用 react-router 的其中一个原因.

在服务端对于 /api 的请求, 全部交给 koa-router 来处理, 对于其他请求则交给 react-router 来处理

  // api server through koa-router
  if (ctx.path.match(/^\/api/)) {
    return await require('./api').routes()(ctx, next)
  }
  // others react-router to render
  await require('./render')(ctx, next)

koa-router 的用法在 platforms/server/routes/api.js 中

react-router 的用法的核心是 match 函数, 我们需要在 React App 中定义一个前后端共用的 routes (路由配置), match 函数在服务端根据这份路由配置进行路由匹配并同步渲染对应的页面出来.

const { redirectLocation, renderProps } = await _match({ routes: require('../../../app/routes'), location: ctx.url })  
if (redirectLocation) {  
  ctx.redirect(redirectLocation.pathname + redirectLocation.search)
} else if (renderProps) {
  await renderCtrl(ctx, next, renderProps)
} else {
  await next()
}

上面的 renderCtrl 是负责服务端渲染的方法. requireroutes 文件就是 react-router 的配置文件

路由配置
// routes.js
export default (  
<Router history={browserHistory}>  
  <Route path="/" component={App}>
    <Route path="picture" component={Picture} />
    <Route path="counter" component={Counter} />
  </Route>
</Router>  
)

数据层

React 生态圈中有丰富的数据层框架供原则, 我采用了现在比较流行也容易上手的 redux.

服务端前置(同步)拉取数据

在服务端前置拉取数据的逻辑参考了 ReactJS 服务端同构实践【QQ音乐web团队】这篇文章的实现方式.

由于在服务端渲染的时候,和前端相比, React 组件的生命周期并不是完整的,即最多只会执行到 componentWillMount
我在 react 组件中约定了一个 fetch 的静态方法(相当于接口), 并需要在前端的 componentDidMount 方法中去判断是否需要在前端做 fetch; 在做服务端渲染的时候遍历需要渲染的 components 去同步调用fetch静态方法得到数据并吐出在页面上作为 reduxinitialState.

  let prefetchTasks = []
  for (let component of renderProps.components) {
    if (component && component.WrappedComponent && component.WrappedComponent.fetch) {
      const _tasks = component.WrappedComponent.fetch(store.getState(), store.dispatch)
      if (Array.isArray(_tasks)) {
        prefetchTasks = prefetchTasks.concat(_tasks)
      } else if (_tasks.then) {
        prefetchTasks.push(_tasks)
      }
    }
  }

  await Promise.all(prefetchTasks)
  await ctx.render('index', {
    title: config.title,
    dev: ctx.app.env === 'development',
    reduxData: store.getState(),
    app: renderToString(<Provider store={store}>
      <RouterContext {...renderProps} />
    </Provider>)
  })  

Component:

class App extends Component {  
  static fetch (state, dispatch) {
    const fetchTasks = []
    fetchTasks.push(
      dispatch(fetchStateIfNeeded(state))
    )
    return fetchTasks
  }

  componentDidMount () {
    const { loaded, success } = this.props
    if ( !loaded || (loaded && !success) ) {
      this.constructor.fetch(this.props, this.props.dispatch)
    }
  }

  render () {
    const { location: { pathname } } = this.props
    const headerCurrent = pathname === '/' ? 'home' : pathname.slice(1)

    return (<div>
      <Header current={headerCurrent}/>
      <Main>{this.props.children}</Main>
      <Footer />
    </div>)
  }
}

模板

配合 koa, 模板引擎用的是最简单的 ejs, 因为在服务端 view 层, 我们只需要做一件事: 填坑.

server render 的时候把 initial State 获取并吐出在页面中

    <section role="main" class="react-container">
      <div><%- app %></div>
    </section>
    <script>
      try {
        window.__REDUX_STATE__ = JSON.parse('<%- JSON.stringify(reduxData) %>');
      } catch (e) {
        console.warn('error in getting server redux data');
      }
    </script>
    <script src="/build/common.js"></script>
    <script src="/build/main.js"></script>

前端入口

使用 window.__REDUX_STATE__ 作为初始 state

const store = configureStore(window.__REDUX_STATE__)  
ReactDOM.render(  
  <Provider store={store}>
    {routes}
  </Provider>,
  document.querySelector('.react-container')
)

前后端兼容

前后端逻辑区分

虽然是同构应用, 大部分逻辑是共用的, 但是在服务端和浏览器端具体的实现肯定是不同的.

例如 superagent / isomorphic-fetch 这些库在服务端是使用的 http.request 方法, 而在前端使用 XHR 来实现. 那么在编写代码中, 前端的 url 可以使用相对路径, 而服务端只能使用完整的 http 请求路径.

const fetchStateUrl = __SERVER__ ? `http://localhost:${require('../../platforms/common/config').port}/api/state` : '/api/state'  

我们可以通过 webpackdefinePlugin 来实现, 但是由于我在 development 模式下是通过 node + babel-register + webpack-dev-middleware 直接运行的应用, 对于服务端运行的代码 webpack 就无能为力了. 我写了一个 babel 的插件 https://github.com/wssgcg1213/babel-plugin-inline-replace-variables 来直接对 jsidentifier 进行替换.

在打包前端 bundle 的时候得益于 uglifyjs 插件的处理, 不可达代码会被清除, 这样也不需要担心 bundle 体积增大.

Nodejs require 静态资源文件的处理

reactcomponent 中充满了import './component.less', import img from './img.png'这样的语法, 但在 node 中无法引入 png, jpg, less, 是会报错的.

我使用了babel-plugin-transform-require-ignore来直接忽略 css/less在服务端的require操作, 转而在 webpack-dev-middleware 中使用 style-loader 打包进 js bundle 输出, 这样也能同时支持 hot module replacement

对于图片则使用了asset-require-hook这个包来使 require 操作直接返回文件路径, 服务端渲染出来的是正确的图片相对路径.

总之, 开发模式下的服务端对 require 函数进行了魔改使它支持了引入 ES6+React+Stylesheets+Images 的能力.

总结

搞两个流程图来看看

开发模式:

开发模式

线上模式:

开发模式

Example

使用这个模式开发了

https://github.com/CQUPTMirror/mirror-web-isomorphic/

线上地址 https://mirror.cqupt.edu.cn

扫描二维码,分享此文章

Ling.'s Picture
Ling.

Web开发者. 前端,NodeJS 😈 大三, 找实习啦 ⬇️戳简历⬇️