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有更好的理解,歡迎留言討論。

參考

相關文章
相關標籤/搜索