react同構實踐——實現本身的同構模板

一開始想學學服務端渲染,腦海中第一個浮現出來的就是next.js這種成熟的方案。看了一兩天,有趣,優雅,可是封裝好了,原理不甚清楚,也感受沒法靈活嵌合到老項目上去。因而看各類資料,想整理出同構的線索,一步一步地實現本身的同構模板。相關代碼可查看個人GitHub。感謝閱讀!!javascript

TODO List

  1. 數據:如何保持先後端應用狀態一致
  2. 路由:路由在服務端和客戶端中的匹配方案
  3. 代碼:同構,哪些地方能夠共享,哪些地方須要差別化
  4. 靜態資源:服務端如何引入css/圖片等
  5. ssr直出資源:服務端在渲染路由頁面時如何匹配css/chunks資源
  6. 打包方案:服務端和瀏覽器端如何寫各自的webpack配置文件
  7. SEO: head頭處理方案

同構的基礎

正常的網頁運行,須要生成dom,在dom樹loaded以後由js綁定相關的dom事件,監聽頁面的交互。服務端並不具有dom的執行環境,於是全部的服務端渲染其實都是返回了一個填充了初始數據靜態文本。在react中,除了經常使用的render這個用於生成dom的方法,還提供了renderToStringrenderToStaticMarkup方法用來生成字符串,因爲VitualDOM的存在,結合這些方法就能夠像之前的字符串模板那樣生成普通的字符串,返回給客戶端接管,再接着進行事件相關的綁定。最新的React v16+使用hydratessr配套,能讓客戶端把服務端的VitualDOM渲染出來後得以複用,客戶端加載js後不會重刷一邊,減少了開銷,也避免瀏覽器重刷dom時帶來的閃屏體驗。而react的組件,仍是和往常寫spa同樣編寫,先後端共享。不一樣的只是入口的渲染方法換了名字,且客戶端會掛載dom而已。css

// clinet.js
ReactDom.hydrate(<App />, document.getElementById('app')) // server.js const html = ReactDom.renderToString(<App />) 複製代碼

同構後網站運行流程圖

盜用一張圖,來自阿里前端。乍一看,ssrcsr的區別就在於2 3 4 5spa模式簡單粗暴地返回一個空白的html頁面,而後在11裏纔去加載數據進行頁面填充,在此以前,頁面都處於空白狀態。而ssr則會根據路由信息,提早獲取該路由頁面的初始數據,返回頁面時已經有了初步的內容,不至於空白,也便於搜索引擎收錄。html

路由匹配

瀏覽器端的路由匹配仍是照着spa來作應該無需費心。略過了...前端

服務端的路由須要關注的,一個是後端服務的路由(如koa-router)匹配的問題,一個是匹配到react應用後react-router路由表的匹配問題。java

  1. 服務端路由,可經過/react前綴來和api接口等其餘區別開來,這種路由匹配方式甚至能讓服務端渲染能同時支持老項目諸如ejs等的模板渲染方式,在系統升級改造方面可實現漸進式地升級。
// app.js文件(後端入口)
import reactController from './controllers/react-controller'
// API路由
app.use(apiController.routes())

// ejs頁面路由
app.use(ejsController.routes())

// react頁面路由
app.use(reactController.routes())

// react-controller.js文件
import Router from 'koa-router'

const router = new Router({
  prefix: '/react'
})

router.all('/', async (ctx, next) => {

  const html = await render(ctx)

  ctx.body = html

})

export default router
複製代碼
  1. 服務端的react-router react-router專供了給ssr使用的StaticRouter接口,稱之爲靜態的路由。誠然,服務端不像客戶端,對應於一次網絡請求,路由就是當前的請求url,是惟一的,不變的。在返回ssr直出的頁面後,頁面交互形成地址欄的變化,只要用的是react-router提供的方法,不管是hash方式,仍是history方式,都屬於瀏覽器端react-router的工做了,因而完美繼承了spa的優點。只有在輸入欄敲擊Enter,纔會發起新一輪的後臺請求。
import { StaticRouter } from 'react-router-dom'
  const App = () => {

    return (
      <Provider store={store}> <StaticRouter location={ctx.url} context={context}> <Layout /> </StaticRouter> </Provider>
    )
  }
複製代碼

應用狀態數據管理

以往的服務端渲染,須要在客戶端網頁下載後立刻能看到的數據就放在服務器提早準備好,可延遲展現,經過ajax請求的數據的交互邏輯放在頁面加載的js文件中去。node

換成了react,其實套路也是同樣同樣的。可是區別在於:react

傳統的字符串模板,組件模板是彼此分離的,可各自單獨引入數據,再拼裝起來造成一份html。而在reactssr裏,頁面只能經過defaultValuedefaultProps一次性render,沒法rerenderwebpack

不能寫死defaultValude,因此只能使用props的數據方案。在執行renderToString以前,提早準備好整個應用狀態的全部數據。全局的數據管理方案可考慮reduxmobx等。git

須要準備初始渲染數據,因此要精準獲取當前地址將要渲染哪些組件。react-router-configreact-router同源配套,是個支持靜態路由表配置的工具,提供了matchRoutes方法,可得到匹配的路由數組。github

import { matchRoutes } from 'react-router-config'

import loadable from '@loadable/component'

const Root = loadable((props) => import('./pages/Root'))
const Index = loadable(() => import("./pages/Index"))
const Home = loadable(() => import("./pages/Home"))

const routes = [
  {
    path: '/',
    component: Root,
    routes: [
      {
        path: '/index',
        component: Index,
      },
      {
        path: '/home',
        component: Home,
        syncData () => {}
        routes: []
      }
    ]
  }
]

router.all('/', async (url, next) => {
  const branch = matchRoutes(routes, url)
})
複製代碼

組件的初始數據接口請求,最美的辦法固然是定義在各自的class組件的靜態方法中去,可是前提是組件不能被懶加載,否則獲取不到組件class,固然也沒法獲取class static method了,不少使用@loadable/component(一個code split方案)庫的開發者屢次提issue,做者也明示沒法支持。不支持懶加載是絕對不可能的了。因此委屈一下代碼了,在須要的route對象中定義一個asyncData方法。

  1. 服務端
// routes.js
{
  path: '/home',
  component: Home,
  asyncData (store, query) {
    const city = (query || '').split('=')[1]
  
    let promise = store.dispatch(fetchCityListAndTemperature(city || undefined))
    
    let promise2 = store.dispatch(setRefetchFlag(false))
  
    return Promise.all([promise, promise2])
    return promise
  }
}

// render.js
import { matchRoutes } from 'react-router-config'
import createStore from '../store/redux/index'

const store = createStore()
const branch = matchRoutes(routes, url)

const promises = branch.map(({ route }) => {
  // 遍歷全部匹配路由,預加載數據
  return route.asyncData
    ? route.asyncData(store, query)
    : Promise.resolve(null)

})
// 完成store的預加載數據初始化工做
await Promise.all(promises)
// 獲取最新的store
const preloadedState = store.getState()

const App = (props) => {

  return (
    <Provider store={store}> <StaticRouter location={ctx.url} context={context}> <Layout /> </StaticRouter> </Provider>
  )
}
// 數據準備好後,render整個應用
const html = renderToString(<App />) // 把預加載的數據掛載在`window`下返回,客戶端本身去取 return ` <html> <head></head> <body> <div id="app">${html}</div> <script> window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)}; </script> </body> </html> ` 複製代碼
  1. 客戶端
  • 爲保證兩端的應用數據一致,客戶端也要使用同一份數據初始化一次redux的store,再生成應用。若是二者的dom/數據不一致,致使瀏覽器接管的時候dom從新生成了一次,在開發模式下的時候,控制檯會輸出錯誤信息,開發體驗完美。後續ajax的數據,在componentDidMount和事件中去執行,和服務端的邏輯自然剝離。
// 獲取服務端提供的初始化數據
const preloadedState = window.__PRELOADED_STATE__ || undefined

delete window.__PRELOADED_STATE__

// 客戶端store初始化
const store = createStore(preloadedState)

const App = () => {

  return (
    <Provider store={store}>

      <BrowserRouter>

        <Layout />
        
      </BrowserRouter>

    </Provider>
  )
}

// loadableReady由@loadabel/component提供,在code split模式下使用
loadableReady().then(() => {
  
  ReactDom.hydrate(<App />, document.getElementById('app'))

})
複製代碼
  • 服務端調用的接口客戶端也必須有。這就帶來了如何避免重複請求的問題。咱們知道componentDidMount方法只執行一次,若是服務器已經請求的數據帶有一個標識,就能夠根據這個標識決定是否在客戶端須要發起一個新的請求了,須要注意的是判斷完成後重置該標識。
import { connect } from 'react-redux'

@connect(
  state => ({
    refetchFlag: state.weather.refetchFlag,
    quality: state.weather.quality
  }),
  dispatch => ({
    fetchCityListAndQuality: () => dispatch(fetchCityListAndQuality()),
    setRefetchFlag : () => dispatch(setRefetchFlag(true))
  })
)
export default class Quality extends Component {
  componentDidMount () {

    const {
      location: { search },
      refetchFlag,
      fetchCityListAndQuality,
      setRefetchFlag
    } = this.props

    const { location: city } = queryString.parse(search)

    refetchFlag 
      ? fetchCityListAndQuality(city || undefined)
      : setRefetchFlag()
  }
}
複製代碼

打包方案

  1. 客戶端打包

我想說的是「照舊」。由於在瀏覽器端運行的仍是spa。入門級的具體見github,至於如何配置得賞心悅目,用起來駕輕就熟,根據項目要求各顯神通吧。

  1. 服務端打包

和客戶端的異同:

同:

  • 須要bable兼容不一樣版本的js語法

webpack v4+/babel v7+ ... 真香

  • ... 留白

異:

  • 入口文件不同,出口文件不同

這裏既能夠把整個服務端入口app.js做爲打包入口,也能夠把react路由的起點文件做爲打包入口,配置輸出爲umd模塊,再由app.jsrequire。之後者爲例(好處在於升級改造項目時儘量地下降對原系統的影響,排查問題也方便,斷點調試什麼的也方便):

// webpack.server.js
const webpackConfig = {
  entry: {
    server: './src/server/index.js'
  },
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js',
    libraryTarget: 'umd'
  }
}

// app.js
const reactKoaRouter = require('./build/server').default
app.use(reactKoaRouter.routes())
複製代碼
  • css、image資源正常來講服務端無需處理,如何繞開

偷懶,還沒開始研究,佔個坑

  • require的是node自帶的模塊時避免被webpack打包

const serverConfig = { ... target: 'node' }

  • require第三方模塊時如何避免被打包

const serverConfig = { ... externals: [ require('webpack-node-externals')() ]

  • 生產環境代碼無需作混淆壓縮
  • ... 留白

服務端直出時資源的蒐集

服務端輸出html時,須要定義好css資源、js資源,讓客戶端接管後下載使用。若是沒啥追求,能夠直接把客戶端的輸出文件全加上去,暴力穩妥,簡單方便。可是上面提到的@loadable/component庫,實現了路由組件懶加載/code split功能後,也提供了全套服務,配套套裝的webpack工具,ssr工具,幫助咱們作蒐集資源的工做。

// webpack.base.js
const webpackConfig = {
  plugins: [ ..., new LoadablePlugin() ]
}

// render.js
import { ChunkExtractor } from '@loadable/server'

const App = () => {

  return (
    <Provider store={store}> <StaticRouter location={ctx.url} context={context}> <Layout /> </StaticRouter> </Provider>
  )
}

const webStats = path.resolve(
  __dirname,
  '../public/loadable-stats.json',  // 該文件由webpack插件自動生成
)

const webExtractor = new ChunkExtractor({ 
  entrypoints: ['client'],   // 爲入口文件名
  statsFile: webStats
})


const jsx = webExtractor.collectChunks(<App />) const html = renderToString(jsx) const scriptTags = webExtractor.getScriptTags() const linkTags = webExtractor.getLinkTags() const styleTags = webExtractor.getStyleTags() const preloadedState = store.getState() const helmet = Helmet.renderStatic() return ` <html> <head> ${helmet.title.toString()} ${helmet.meta.toString()} ${linkTags} ${styleTags} </head> <body> <div id="app">${html}</div> <script> window.STORE = 'love'; window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)}; </script> ${scriptTags} </body> </html> ` 複製代碼

SEO信息

上面已經透露了。使用了一個react-helmet庫。具體用法可查看官方倉庫,信息可直接寫在組件上,最後根據優先級提高到head頭部。

相關文章
相關標籤/搜索