react同构实践——实现自己的同构模板

栏目: 服务器 · 发布时间: 5年前

内容简介:一开始想学学服务端渲染,脑海中第一个浮现出来的就是next.js这种成熟的方案。看了一两天,有趣,优雅,但是封装好了,原理不甚清楚,也感觉无法灵活嵌合到老项目上去。于是看各种资料,想整理出同构的线索,一步一步地实现自己的同构模板。相关代码可查看正常的网页运行,需要生成dom,在dom树loaded之后由js绑定相关的dom事件,监听页面的交互。服务端并不具备dom的执行环境,因而所有的服务端渲染其实都是返回了一个填充了盗用一张图,来自阿里前端。乍一看,

一开始想学学服务端渲染,脑海中第一个浮现出来的就是next.js这种成熟的方案。看了一两天,有趣,优雅,但是封装好了,原理不甚清楚,也感觉无法灵活嵌合到老项目上去。于是看各种资料,想整理出同构的线索,一步一步地实现自己的同构模板。相关代码可查看 我的GitHub 。感谢阅读!!

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而已。

// clinet.js
ReactDom.hydrate(<App />, document.getElementById('app'))

// server.js
const html = ReactDom.renderToString(<App />)
复制代码

同构后网站运行流程图

盗用一张图,来自阿里前端。乍一看, ssrcsr 的区别就在于 2 3 4 5spa 模式简单粗暴地返回一个空白的html页面,然后在 11 里才去加载数据进行页面填充,在此之前,页面都处于空白状态。而 ssr 则会根据路由信息,提前获取该路由页面的初始数据,返回页面时已经有了初步的内容,不至于空白,也便于搜索引擎收录。

react同构实践——实现自己的同构模板

路由匹配

前端的路由匹配还是照着 spa 来做应该无需费心。略过了...

后端的路由需要关注的,一个是服务端路由(如 koa-router )匹配的问题,一个是匹配到react页面后 react-router 路由表的匹配问题。

  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 文件中去。

换成了 react ,其实套路也是一样一样的。但是区别在于:

传统的字符串模板,组件模板是彼此分离的,可各自单独引入数据,再拼装起来形成一份 html 。而在 reactssr 里,页面只能通过 defaultValuedefaultProps 一次性 render ,无法 rerender

不能写死 defaultValude ,所以只能使用 props 的数据方案。在执行 renderToString 之前,提前准备好整个应用状态的所有数据。全局的数据管理方案可考虑 reduxmobx 等。

需要准备初始渲染数据,所以要精准获取当前地址将要渲染哪些组件。 react-router-configreact-router 同源配套,是个支持静态路由表配置的工具,提供了 matchRoutes 方法,可获得匹配的路由数组。

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 头部。


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

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

极致产品

极致产品

周鸿祎 / 中信出版社 / 2018-6 / 58.00

周鸿祎作为*知名的产品经理之一,一手打造了众多国民级的产品。他关于打造爆款的理念,比如刚需、高频、“小白”思维等,已成为网络热词而被广泛接受。 本书是周鸿祎首次系统总结其20年产品经理的心得,不仅将以往的理念进行总结、归纳,而且在与包括各方面创业者、产品经理的碰撞后,将其观念进一步升华,成为迄今为止首部将其产品理念倾囊相授的作品。一起来看看 《极致产品》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换