react 原生构建 SSR 框架及 NSR 实践方案的思考

前言

SSR技术方案其实已经不算什么新颖的技术了,说的简单点,就是服务端直接返回html字符串给浏览器,浏览器直接解析html字符串生成DOM结构, 不但能够减小首屏渲染的请求数,并且对搜索引擎的蜘蛛抓取颇有效果。php

此次主要介绍下如何使用react原生去实现SSR的整个工做流程,固然目前有比较成熟的方案,就是使用next.js,其实该框架实现的基本原理是同样的,回到最开始说的,从html字符串到网页中的DOM结构,通过分析,须要解决如下几个问题:css

  • 请求的整个流程是怎样的?
  • 服务端如何将拼装好的异步数据及同步数据返回给客户端?
  • html字符串中给标签绑定的方法是如何绑定上的?
  • SSR场景下如何实现组件的按需加载?

若是你对上几个问题都很是清楚,那么你能够略过如下的内容,尝试去搭建一套属于你本身的SSR框架,固然若是有疑问,能够继续阅读,若是想直接查看源码,这里附上传送门html

请求的整个流程是怎样的?

废话很少讲,先附上一张流程图,基本以下:java

这里须要解释下,上图符合首次渲染的流程,除了首次渲染以外的,你是使用node作转发层仍是直接调用java/phpapi 彻底取决于你的实际场景,本次示例主要是依照这样的流程构建的node

大体将流程分为如下三个部分:react

第一个流程

node服务从java获取数据,node主要经过页面路由来判断须要加载哪一个路由的初始化数据,非命中路由不作任何处理,这一阶段的耗时主要取决于node服务与java服务的通讯时间。webpack

第二个流程

node服务将获取的数据和html的基本结构等拼装好返回给客户端进行解析,而且完成html字符串中外链js资源的加载过程,这个阶段主要作两件事:ios

  • 拼装数据中主要包含:ajax返回数据html基础结构css初始化数据meta、title等信息内容
  • html字符串中标签上的方法绑定和外链 js 代码执行

第三个流程

同构代码中componentDidMount生命周期触发ajax请求,获取服务端数据,这里须要解释下,若是是命中路由的页面,这里能够作一个判断,就是若是本地存在数据,这里能够不发送请求,反之则发送请求。git

固然这里可能会涉及到数据同步的问题,一样能够设计一个api通知页面是否须要从新拉取数据便可。github

项目目录结构说明

涉及到主要的库有:

更多能够查看源码中的package.json文件

  • react
  • react-loadable
  • react-router-config
  • redux-saga
  • axios
  • express
├── build // 打包目录
│   ├── webpack.base.config.js
│   ├── webpack.client.config.js
│   └── webpack.server.config.js
├── build-client // 客户端打包文件夹
├── build-server // 服务端打包文件夹
├── config // 打包相关配置文件
└── src // 同构代码源码目录
    ├── App.js // 同构代码入口文件
    ├── assets // 须要引入项目中的静态资源
    ├── components // 公共组件文件夹
    ├── components-hoc // 高阶组件文件夹
    ├── entry-client // 客户端入口文件夹
    │   └── index.js
    ├── entry-server // 服务端入口文件夹
    │   ├── index.js
    │   └── renderContent.js
    ├── public // 公共函数方法等
    ├── router // 路由配置文件夹
    ├── static // 直接会打包生成到entry-client文件夹下,不会直接引入项目中
    ├── store // 共享数据store文件夹
    └── views // 不一样页面文件夹
复制代码

开始搭建

项目采用 saga 中间件管理 stroe,因此定义组件的loadData以及入口文件导入store写法会有所不一样,后面会说到。

既然流程清楚了,大体的目录结构也清晰了,那么就能够开始着手搭建属于本身的SSR框架了。开启服务端渲染,就必然要理解代码同构的问题,其实就是一套代码既然服务端运行同时也在客户端运行,固然也就会有不一样的打包逻辑出现,相应的出现客户端的入口文件以及服务端的入口文件。

客户端入口文件

先打开entry-client/index.js,基本以下:

import React, { Fragment } from 'react'
import ReactDom from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { renderRoutes } from 'react-router-config'
import Loadable from 'react-loadable'
import { configureClientStore } from '../store/index'
import routes from '../router'
import rootSaga from '../store/rootSagas'

const store = configureClientStore()
store.runSaga(rootSaga)

const App = () => {
  return (
    <Provider store={store}> <BrowserRouter> <Fragment>{renderRoutes(routes)}</Fragment> </BrowserRouter> </Provider>
  )
}
Loadable.preloadReady().then(() => {
  ReactDom.hydrate(<App />, document.getElementById('root')) }) 复制代码

这里使用的是react-router-config中的renderRoutes方法来渲染路由对象,由于在定义路由使用的是对象形式定义,片断代码基本以下:

// src/router/index.js
export default [
  {
    path: '/',
    component: App,
    key: 'app',
    routes: [
      {
        path: '/',
        component: Home,
        exact: true,
        loadData: Home.loadData,
        key: 'home'
      },
      {
        component: NotFound,
        key: 'notFound'
      }
    ]
  }
]
复制代码

这里导入的store并非经过createSagaMiddleware直接生成的,而是一个函数configureClientStore,执行以后上面会携带一个runSaga方法,再次执行了createSagaMiddleware().run,至于为何会这样引入,在服务端入口文件会进一步说明。

你会发现,在最终和页面dom绑定的使用的是ReactDom.hydrate而不是ReactDom.render方法,其实你在这使用render也不会出问题,只是性能上会有所损耗,其实从react源码上看也能得出这个结论,hydraterender方法内部都会调用legacyRenderSubtreeIntoContainer方法,只是在第四个参数上不一样,hydraterender分别是truefasletrue则表明须要复用客户端渲染的DOM结构,具体详细能够参考react 代码分析

这就是为何前面会先说同构的概念,既然代码服务端和客户端都渲染一遍,确定会有性能上的损耗,固然若是使用ReactDom.hydrate最起码能够复用客户端的DOM结构,也会减小性能损耗。

Loadable按需加载的配置会单独说明。

到这里,客户端的入口文件基本说明清楚,其余的配置都比较常规,就不作详细介绍了。

服务端入口文件

服务端相对会比客户端复杂点,由于涉及到异步数据的获取以及相关同步数据的获取,而且拼装数据返回给客户端,大体完成这个流程。完整代码以下:

import express from 'express'
import proxy from 'express-http-proxy'
import { matchRoutes } from 'react-router-config'
import { all } from 'redux-saga/effects'
import Loadable from 'react-loadable'
import { renderContent } from './renderContent'
import { configureServerStore } from '../store/'
import routes from '../router'
import C from '../public/conf'

const app = express()

app.use(express.static('build-client'))

app.use(
  '/api',
  proxy(`${C.MOCK_HOST}`, {
    proxyReqPathResolver: req => {
      return `/api/` + req.url
    }
  })
)

app.get('*', (req, res) => {
  const store = configureServerStore()

  const matchedRoutes = matchRoutes(routes, req.path)
  const matchedRoutesSagas = []
  matchedRoutes.forEach(item => {
    if (item.route.loadData) {
      matchedRoutesSagas.push(item.route.loadData({ serverLoad: true, req }))
    }
  })

  store
    .runSaga(function* saga() {
      yield all(matchedRoutesSagas)
    })
    .toPromise()
    .then(() => {
      const context = {
        css: []
      }
      const html = renderContent(req, store, routes, context)

      // 301重定向设置
      if (context.action === 'REPLACE') {
        res.redirect(301, context.url)
      } else if (context.notFound) {
        // 404设置
        res.status(404)
        res.send(html)
      } else {
        res.send(html)
      }
    })
})
Loadable.preloadAll().then(() => {
  app.listen(8000, () => {
    console.log('8000启动')
  })
})
复制代码

命中客户端路由使用的是app.get('*')这个很好理解,而后经过matchRoutes(routes, req.path)来匹配出当前路由下的全部路由及子路由的信息内容。

matchedRoutes.forEach(item => {
  if (item.route.loadData) {
    matchedRoutesSagas.push(item.route.loadData({ serverLoad: true, req }))
  }
})
复制代码

loadData后面会讲到在哪定义,原则是在包装后的组件上自定义给服务端使用的异步获取数据的方法

经过遍历matchedRoutes,找出定义在路由上的loadData方法,而且使用matchedRoutesSagas来收集这些方法,其实这里很好理解,就是将首次渲染涉及到的全部路由上应该要加载的数据方法统一收集,而后再统一调用,最后返回。

在执行matchedRoutesSagas这里面的loadData就用到了store.runSaga()方法,里面传入Generator function,这样就能调用在saga中定义的Generator函数,也就是 matchedRoutesSagas集合,同时store.runSaga()返回的是一个task,该task会有一个toPromise方法,

以后就很好理解了,就是等待被命中路由上的全部loadData方法执行完了,再then执行相关的方法,其实在then以后,store上已经有了异步数据了,接下来就是res.send()返回给客户端就好了。

这里是为了更好的代码结构,从新定义了一个renderContent文件专门处理拼接字符串使用的,完整代码基本以下:

import React, { Fragment } from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { renderRoutes } from 'react-router-config'
import { Helmet } from 'react-helmet'
import minify from 'html-minifier'
import Loadable from 'react-loadable'
import { getBundles } from 'react-loadable/webpack'
import stats from '../../build-client/react-loadable.json'

export const renderContent = (req, store, routes, context) => {
  let modules = []
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path} context={context}>
        <Loadable.Capture report={moduleName => modules.push(moduleName)}>
          <Fragment>{renderRoutes(routes)}</Fragment>
        </Loadable.Capture>
      </StaticRouter>
    </Provider>
  )
  let bundles = getBundles(stats, modules)
  const helmet = Helmet.renderStatic()
  const cssStr = context.css.length ? context.css.join('\n') : ''
  const minifyStr = minify.minify(
    `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        ${helmet.title.toString()}
        ${helmet.meta.toString()}
        <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no">
        <link rel="icon" href="/static/favicon.ico" type="image/x-icon">
        <link href="/static/css/reset.css" rel="stylesheet" />
        <script src="/static/js/rem.js"></script>
        <style>${cssStr}</style>
      </head>
      <body>
        <div id="root">${content}</div>
        <script>
          window.context = {
            state: ${JSON.stringify(store.getState())}
          }
        </script>
        ${bundles
          .map(bundle => {
            return `<script src="/${bundle.file}"></script>`
          })
          .join('\n')}
        <script src='/client-bundle.js'></script>
      </body>
    </html>
  `,
    {
      collapseInlineTagWhitespace: true,
      collapseWhitespace: true,
      processConditionalComments: true,
      removeScriptTypeAttributes: true,
      minifyCSS: true
    }
  )
  return minifyStr
}
复制代码

其实这里最终的要的一个方法就是renderToString,将storeroutes转成字符串,而且这里使用的是StaticRouter静态路由标签,这也是官网上介绍使用的,毕竟最后输出的是一个字符串,服务端渲染路由也是无状态的,不像上面在介绍客户端入口文件中,使用的是BrowserRouter,它是使用HTML5提供的history API (pushState, replaceState 和 popstate 事件)来保持 UIURL 的同步。

StaticRouter会传递context参数,在同构代码中将会以staticContext出如今props属性上,后面会介绍到。

context.css.length是判断是否有样式数据内容

这里在 return 拼装的字符串时,会在widnow对象上定义一个context.state,而且赋值JSON.stringify(store.getState()),主要就是能够在store的配置文件中起到合并状态的依据,由于只有合并状态数据,这样store上就会有异步数据,这样首次渲染的时候,组件就能够直接使用异步数据渲染。后面还会贴代码说明下。

在字符串中会定义一个<script src='/client-bundle.js'></script>这个内容,链接的是客户端打包文件,这里就涉及到最开始流程中的第二个流程,客户端的 js 代码会在浏览器端运行一遍,执行组件的生命周期以及绑定相应的方法等操做。

你会发现这里也会有Loadable的相关配置,后面会说下配置Loadable须要注意的地方。

同构代码内容

介绍了客户端入口文件及服务端入口文件,主要以一个组件为示例介绍下同构代码中须要作相应处理的地方。

打开src/view/home/index.jssrc/view/home/head/index.js,片断源码以下:

// home/index.js
render() {
  const { staticContext } = this.props
  return (
    <Fragment>
      <Header staticContext={staticContext} />
      <Category staticContext={staticContext} />
      <ContentList staticContext={staticContext} />
      <BottomBar staticContext={staticContext} />
    </Fragment>
  )
}


// head/index.js
import InjectionStyle from '../../../components-hoc/injectionStyle'
import styles from './index.scss'

class Header extends Component {
  constructor(props) {
    super(props)
    this.state = {}
  }
  render() {
    const { staticContext } = this.props
    return (
      <div className={styles['header']}>
        <SearchBar staticContext={staticContext} />
        <img
          className={styles['banner-img']}
          src="//xs01.meituan.net/waimai_i/img/bannertemp.e8a6fa63.jpg"
        />
      </div>
    )
  }
}

Header.propTypes = {
  staticContext: PropTypes.any
}

export default InjectionStyle(Header, styles)
复制代码

若是是嵌套组件则须要将staticContext传递下去,不然子组件获取不到staticContext的内容,再一个这里的staticContext主要是给服务端使用的是,用来收集处理当前组件的样式数据。

根据Header组件中InjectionStyle(Header, styles),打开高阶组件源码,基本以下:

import React, { Component } from 'react'
export default (CustomizeComponent, styles) => {
  return class NewComponent extends Component {
    componentWillMount() {
      const { staticContext } = this.props
      if (staticContext) {
        staticContext.css.push(styles._getCss())
      }
    }
    render() {
      return <CustomizeComponent {...this.props} /> } } } 复制代码

其实很简单,就是包装了下组件,而且将将组件的样式数据pushstaticContext.css中,可能会问,这里的css在哪定义的呢,若是能理解最开始流程,会很容易想到,就是在服务端拼装数据以前,将这些变量定义好,而后经过StaticRoutercontext参数,向下传递。具体能够查看服务端入口文件内容。

在同构代码中还有一个比较关键的就是store的配置,入口文件源码以下:

import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import reducers from './rootReducers'

const sagaMiddleware = createSagaMiddleware()

export const configureClientStore = () => {
  const defaultState = window.context.state
  return {
    ...createStore(reducers, defaultState, applyMiddleware(sagaMiddleware)),
    runSaga: sagaMiddleware.run
  }
}

export const configureServerStore = () => {
  return {
    ...createStore(reducers, applyMiddleware(sagaMiddleware)),
    runSaga: sagaMiddleware.run
  }
}
复制代码

最终输出的分为两个configureClientStoreconfigureServerStore,惟一的区别就是在configureClientStore中会有一个合并状态的操做,这就是以前介绍的,服务端在再获取异步数据,而且将异步数据定义在widnow对象上,这样在客户端代码再次运行的时候,就会执行这里的方法,从而直接从合并以后的store中取数据渲染组件。

Loadable 配置

主要使用的是react-loadable包来实现按需加载,在SSR增长这个配置相对比较繁琐,可是官网基本已经给出详细的步骤详细配置流程,固然本源码中也已经实现,只是在配置的过程当中须要注意的一点,任然是loadData的定义,以前说过,只有在包装后的组件定义loadData才会生效,因此我将loadData定义在路由的配置文件中,目前只是经过这样实现,我的以为从文件组织上不是很理想,src/router/index.js源码基本以下:

import Loadable from 'react-loadable'
import LoadingComponent from '../components/loading'
import { getInitData } from '../store/home/sagas'

const Home = Loadable({
  loader: () => import('../views/Home'),
  modules: ['../views/Home'],
  webpack: () => [require.resolveWeak('../views/Home')],
  loading: LoadingComponent
})

Home.loadData = serverConfig => {
  const params = {
    page: 1
  }
  return getInitData(serverConfig, params)
}

const NotFound = Loadable({
  loader: () => import('../views/NotFound'),
  modules: ['../views/NotFound'],
  webpack: () => [require.resolveWeak('../views/NotFound')],
  loading: LoadingComponent
})

export default [
  {
    path: '/',
    component: App,
    key: 'app',
    routes: [
      {
        path: '/',
        component: Home,
        exact: true,
        loadData: Home.loadData,
        key: 'home'
      },
      {
        component: NotFound,
        key: 'notFound'
      }
    ]
  }
]
复制代码

若是直接在Home上定义loadData而后在使用Loadable包装以后,loadData会不存在,因此暂时是经过以上这样实现的,若是你们有更好的实现方式,也能够留言讨论。

总结

感谢@DellLee 老师的分析,在此基础上增长了我的的思想及相关配置。以上就是所有内容,并无很详细的介绍代码逻辑,只是选了几个比较关键点来描述,其实这些也能回答最开始遗留的 4 个问题。具体详细内容能够参考源码进行测试,SSR虽然能提升首屏渲染以及提高 SEO 效果,可是同时也增长了服务端的压力。其实也能够尝试使用预渲染方案。

后记

以前看了一遍号称0.3s 完成渲染,提出了一个新的架构NSR。下图是优化以前和优化以后的体验效果:

下图是渲染流程设计:

通篇看完以后,就是将客户端的APP看成一个SSR的服务,这个设计有个好处就是点击新闻列表任何一篇文章都会触发SSR渲染(以致于个人手机使用 UC 浏览器发热很快,原来是拿我当服务使用),不像我最开始介绍是使用node服务作SSR渲染,只在首屏渲染触发,文章也说了,NSR能够说就是分布式SSR

其实有一点不解的是,最终仍是须要有ajax请求存在,无非是NativeAPI服务请求数据,可是依然会存在白屏的状况,有请求就会出现白屏。

除非交互是在渲染 10 条新闻列表时,偷偷的将十条新闻内容发请求获取了,而后再点击文章才不会出现白屏,可是实际这种交互,成本太大,我想应该不会采用。那么只能是用户触发点击后取拉取数据,再到界面显示数据,这个过程感受必出现白屏。

以前理解有错误,实际上就是在你加载新闻列表的同时,将新闻内容统一经过ajax获取了,这样其实会有必定流量上的损失,可是若是能加上人物画像,经过人物画像能精准判断哪些文章能够预先加载,哪些不须要加载,这样获取也能在必定程度上增长用户体验。

总的来说,他的这个思路很不错,值得学习。若是你们对这个NSR有更好的理解,欢迎留言讨论。

参考

相关文章
相关标签/搜索