React SSR 工程化最佳实践(基于 koa 和 context api)

栏目: Node.js · 发布时间: 4年前

内容简介:自从网上关于前面有两张示意图,为了方便,我直接用了豆瓣和掘金的 api 来做数据展示,logo 直接用了豆瓣(不要在意这些细节:joy:)。
React SSR 工程化最佳实践(基于 koa 和 context api)
React SSR 工程化最佳实践(基于 koa 和 context api)

:rocket: 前言

自从 react vue angularmvvm 前端框架问世之后,前后端分离使得分工更加明确,开发效率显著提高。 由以前的后端渲染数据吐页面变成了前端请求数据,渲染页面,所以在客户端渲染中必须先下载服务器的 js css 文件再进行渲染。这需要一定的时间,中间的白屏对用户来说也不是很友好,而且爬虫抓取到的页面是一个无内容的空页面,也不利于 seo 。因此在前端框架基础上的 ssr 也成了刚需。 ssr 的好处也十分明显

1.  利于 seo
2.  加快首屏加载,解决首屏的白屏问题
...
复制代码

网上关于 react ssr 的文章成千上万,虽然原理相同,但每个人的实现方式风格迥异,而且很多都有着复杂的配置和代码逻辑,能把 ssr 解释清楚的少之又少,所以我认真研究了一下 react ssr 的实现,在同事的启发下搭了一个自己的 react ssr & csr 同构框架,只有一个目的,那就是争取把 ssr 讲得谁都能看懂。

前面有两张示意图,为了方便,我直接用了豆瓣和掘金的 api 来做数据展示,logo 直接用了豆瓣(不要在意这些细节:joy:)。

:rocket: github ,项目地址在这里

下面正式开始介绍本项目中 SSR 的实现

:car: SSR 原理

React SSR 工程化最佳实践(基于 koa 和 context api)

本项目的客户端服务端同构大概是这样一个流程,总的来说就是在服务器和客户端之间加一成 node,这一层的作用是接收到客户端的请求之后,在这一层请求数据,然后把数据塞到 html 模版页面中,最后吐出一个静态页面,所有耗时的操作都在服务端完成。当然,服务端返回的只是一个静态的 html,想要交互、dom 操作的话必须运行一遍客户端的js,因此在吐页面的时候要把cdn上的js也插入进来,剩下的就交给客户端自己去处理了。这样才算完成同构。在开始之前,我先抛出几个新人容易困惑的问题(其实是我之前困惑的几个问题)

1. 服务端如何将客户端的异步请求劫持并完成请求,渲染页面呢?
2. 服务端请求回来的数据,在运行客户端js的时候会不会被覆盖呢?
3. 服务端返回一个没有样式的 html 的话会影响体验,如何在服务端就插入样式呢?
...

复制代码

带着这些问题,我们开始研究下ssr的实现

:book: 项目结构

首先介绍下项目结构

React SSR 工程化最佳实践(基于 koa 和 context api)

如上图,这是我尝试了多种结构之后确定下来的,我一直很看重项目结构的设计,一个清晰的框架能让自己的思路更加清晰。client 和 server 分别是客户端和服务端的内容,client 中有我们的 pages 页面,components 共用组件,utils 是常用 工具 函数。如果项目不需要 ssr 的话,client 端也能单独跑起来,这就是客户端渲染了,本项目的服务端和客户端分别跑在 8987,8988 端口。lib 文件夹下是全局的一些配置和服务, 包括 webpack 等。

项目开发或者打包的时候,会启动 serverclient 两条编译流水线

打包的文件也在 build 下的 serverclient 文件夹下,怎么样,这个结构是不是灰常清晰易懂。

路由等配置就不贴源码了,感兴趣的可以看一下源码。

:rocket:打包的过程是重点,一个 webpack 配置文件通用,配置的部分参数需要根据客户端还是服务端、开发还是生产环境来区分。

const webpackConfig = termimal => {

  // 在这里区分 环境和终端,终端需要在编译的时候传参数进来
  const isProd = process.env.NODE_ENV === 'production'
  const isDev = process.env.NODE_ENV === 'development'
  const isServer = termimal === 'server'
  const isClient = termimal === 'client'
  const isDevServer = isDev && isServer
  const isProdClient = isProd && isClient
  const isProdServer = isProd && isServer
  const target = isServer ? 'node' : 'web'

  return {
    bail: isProd,
    mode: isProd ? 'production' : 'development',
    target,
    // 客户端和服务端的入口文件分别为 client 和 server 下的 index.js
    entry: createEntry(termimal),
    output: {
      filename: `[name]${isProdClient ? '.[chunkhash]' : ''}.js`,
      path: isServer ? paths.buildServer : paths.buildClient,
      publicPath: '',
      libraryTarget: isServer ? 'commonjs2' : 'var',
    },
    plugins: [
      // ssr 的产物由 StartServerPlugin 启动
      isDevServer && new StartServerPlugin({
        name: 'main.js',
        keyboard: true,
        signal: true
      }),
      // 客户端渲染时把 html 模版打包
      isClient && new HtmlWebpackPlugin(
        Object.assign(
          {},
          {
            inject: true,
            template: paths.appHtml,
          },
          isProd
            ? {
                minify: {
                  removeComments: true,
                  collapseWhitespace: true,
                  removeRedundantAttributes: true,
                  useShortDoctype: true,
                  removeEmptyAttributes: true,
                  removeStyleLinkTypeAttributes: true,
                  keepClosingSlash: true,
                  minifyJS: true,
                  minifyCSS: true,
                  minifyURLs: true,
                },
              }
            : undefined
        )
      )
    ].filter(Boolean),

    // 服务端 node 环境需要引入这个插件
    externals: [
      isServer && nodeExternals()
    ].filter(Boolean),

    // devServer 的相关配置
    devServer: {
      allowedHosts: [".localhost"],
      disableHostCheck: false,
      compress: true,
      port: config.project.devServer.port,
      headers: {
        'access-control-allow-origin': '*'
      },
      hot: true,
      publicPath: '',
      historyApiFallback: true
    }
  }
}
复制代码

:package:以上就是项目的 webpack 配置,相较于分开几个文件来写,我更喜欢这种方式。

运行 run dev 之后会启动客户端和服务端的编译:

const webpack = require('webpack')
const WebpackDevServer = require('webpack-dev-server')
const open = require('open')
const path = require('path')
const webpackConfig = require(path.resolve('lib/webpackConfig'))
const config = require(path.resolve(__dirname, '../package.json'))

// 客户端编译
const clientConfig = webpackConfig('client')
const clientCompiler = webpack(clientConfig)
const clientDevServer = new WebpackDevServer(
  clientCompiler,
  clientConfig.devServer
)
clientDevServer.listen(config.project.devServer.port)

// 服务端编译
const serverConfig = webpackConfig('server')
const serverCompiler = webpack(serverConfig)
serverCompiler.watch({
  quiet: true,
  stats: 'none'
})

复制代码
React SSR 工程化最佳实践(基于 koa 和 context api)

下面是我的服务端处理,由于引入了 babel,所以我在服务端可以不遵循 conmmonjs 模块规范而使用 es6 模块

import Koa from 'koa'
import path from 'path'
import debug from 'debug'
import Router from 'koa-router'
import koaStatic from 'koa-static'
import bodyParser from 'koa-bodyparser'
import favic from 'koa-favicon'
import packageJson from '../package.json'
import ReactServer from './App'
import {routes} from 'client/pages'

const server = new ReactServer()

const log = (target, port) => debug(`dev:${target}  The ${target} side rendering is running at http://localhost:${port}`)

const app = new Koa()
const router = new Router()

app.use(bodyParser({
  jsonLimit: '8mb'
}))

// 对所以的路由都返回这个页面了
router.get('*', async ctx => {

  //  匹配页面的实际路由
  const currentRoute = routes.find(r => r.path === ctx.request.url)

  const currentComponent = currentRoute && currentRoute.component
  
  // 把页面中的请求劫持过来在服务端发
  const { fetchId, getInitialProps } = currentComponent || {}
  const currentProps = getInitialProps && await getInitialProps()

  // 服务端请求到的数据
  const contextProps = {
    [fetchId]: {
      data: currentProps,
      pending: false,
      error: null
    }
  }
  ctx.body = server.renderApp(ctx, contextProps)
})

// 静态
app.use(koaStatic(path.join(__dirname, '../build')))

app.use(
  favic(path.resolve(__dirname, '../public/favicon.ico'), {
    maxAge: 1000 * 60 * 10
  })
);

app.use(router.routes())

// 处理 server hot reload
if (module.hot) {
  process.once('SIGUSR2', () => {
    log('Got HMR signal from webpack StartServerPlugin.')
  })
  module.hot.accept()
  module.hot.dispose(() => server.close())
}

app.listen(packageJson.project.port, () => {
  log('server', packageJson.project.port)('')
  log('client', packageJson.project.devServer.port)('')
})

复制代码

这里的 contextProps 又是怎么传递到我们的页面中的呢,这就是 React 16.3 推出的新的 context api 了,熟悉的人应该一眼就能看懂,不太熟悉 context api 建议看一下相关文档,也十分简单。为什么我这里不用 redux 或者 mobx 呢,这就纯粹是个人喜好了,redux 相对来说比较重,而且开发工程中需要配置 action 和 reducer,写起来比较繁琐,mobx 相对来说较轻。这里采用了 contextApi,因为它相对来说更加简洁,且易于配置。

// 创建上下文
const AppContext = React.createContext('')

// 由 Provider 提供 props
<AppContext.Provider value={this.state}>
    {this.props.children}
</AppContext.Provider>

// 由 Consumer 接收 props
<AppContext.Consumer>
    {this.props.children}
</AppContext.Consumer>

复制代码

上面是 context 大致的工作原理,基于此,项目中抽出了一个统一的 app 生成器:

import React from 'react'
import Pages from 'client/pages'
import AppContextProvider from 'hocs/withAppContext'

// 这里由 client 和 server 端共享,context 由外部传入,这里就有了全局的 props 了。
export const renderBaseApp = context => {

  return (
    <AppContextProvider appContext={context}>
      <Pages />
    </AppContextProvider>
  )
}

export default renderBaseApp
复制代码

于是在页面中配置异步请求:

const fetchId = 'highRateMovie'

class HighRateMovie extends React.Component {
 ......
}

HighRateMovie.fetchId = fetchId

// 该组件下绑定的异步逻辑,供服务端抓取
HighRateMovie.getInitialProps = () => fetch(addQuery('https://movie.douban.com/j/search_subjects', {
  type: 'movie',
  tag: '豆瓣高分',
  sort: 'recommend',
  page_limit: 40,
  page_start: 0
}))

export default HighRateMovie
复制代码

这里的 fetchId 是作为全局 context 对象的键来用的,不能重复,最后页面中的数据结构会是:

{
    movies: [],
    music: {},
    heroList: []
    ...
}

复制代码

这里的fetchId 就成了唯一标识。

服务端渲染的时候就能抓取到这个请求,并把请求回来的数据塞进 context 中,通过 Provider 提供给所有的组件。

dangdangdang 重点在下面,所谓同构,就是服务端吐一个 html 页面,但是页面绑定的点击等事件如何执行呢,服务端是没有 dom 这个概念的,因此最最重要的同构就是吐出来的 html 仍然要加载客户端打包的 js 完成相关事件的绑定

import React from 'react'
import path from 'path'
import fs from 'fs'
import Mustache from 'mustache'
import {StaticRouter} from 'react-router-dom'
import {renderToString} from 'react-dom/server'
import { getBuildFile, getAssetPath } from './utils'
import template from './template'
import renderBaseApp from 'lib/baseApp'

let ssrStyles = []

// 创建一个 ReactServer 类供服务端调用,这个类处理与 html 模版相关的一切东西
class ReactServer {
  constructor(props) {
    Object.assign(this, props)
  }

// 获取客户端所有的打包的文件
  get buildFiles() {
    return getBuildFile()()
  }

// 获取需要的打包文件,这里只需要js文件
  get vendorFiles() {
    return Object.keys(this.buildFiles).filter(key => {
      const item = this.buildFiles[key]
      return path.extname(item) === '.js'
    })
  }

// 拼接 script 标签字符串,接收 context 参数存储数据
  getScripts(ctx) {
    return this.vendorFiles
    .filter(item => path.extname(item) === '.js')
    .map(item => `<script type="text/javascript" src='${getAssetPath()}${item}'></script>`)
    .reduce((a, b) => a + b, `<script type="text/javascript">window._INIT_CONTEXT_ = ${JSON.stringify(ctx)}</script>`)
  }

// 服务端渲染初期就把 css 文件添加进来, 由于 isomorphic-style-loader提供给我们了
_getCss()这个方法,因此可以将 css 文件在服务端拼接成 style 标签,得到的页面最开始就有了样式
 
  getCss() {
    // 读取初始化样式文件
    const cssFile = fs.readFileSync(path.resolve(__dirname, '../client/index.css'), 'utf-8')
    const initStyles = `<style type="text/css">${cssFile}</style>`
    const innerStyles = `<style type="text/css">${ssrStyles.reduceRight((a, b) => a + b, '')}</style>`
    
    // 服务端 css 包含两部分,一个是初始化样式文件,一个是 css modules 生成的样式文件,都在这里插进来
    return initStyles + innerStyles
  }

// 这个方法提供给 withStyle hoc 使用,目的是把页面中的样式都提取出来
  addStyles(css) {
    const styles = typeof css._getCss === 'function' ? css._getCss() : ''
    if(!ssrStyles.includes(styles)) {
      ssrStyles.push(css._getCss())
    }
  }

  renderTemplate = props => {
    return Mustache.render(template(props))
  }

  renderApp(ctx, context) {
    const html = renderToString((
      <StaticRouter location={ctx.url} context={context}>
        
        // 这里统一下发一个 addStyles 函数供 withStyle hoc 使用,可以理解为下发一个爪子,把组件中的样式都抓回来
        {renderBaseApp({...context, addStyles: this.addStyles, ssrStyles: this.ssrStyles})}
      </StaticRouter>
    ))

    return this.renderTemplate({
      title: '豆瓣', 
      html, 
      scripts: this.getScripts(context), 
      css: this.getCss()
    })
  }
}

export default ReactServer
复制代码

大家可能注意到了,我在插入客户端打包后的脚本时,还插入了这样一个脚本

<script type="text/javascript">window._INIT_CONTEXT_ = ${JSON.stringify(ctx)}</script>
复制代码

这是因为同构之前客户端和服务端是两个服务,数据无法共享,我在服务端把数据下发之后,在执行客户端的js过程中又被客户端初始化清空了,可是我数据明明都已经有了啊,这一清空前面不都白做了吗,啊摔...

为了解决这个问题,就在这里多插入一个脚本,存我们初始化的数据,在客户端渲染的过程中,初始的context 直接从window中获取就可以了

class App extends React.Component {
  render() {
    return (
      <BrowserRouter>
        {renderBaseApp(window._INIT_CONTEXT_)}
      </BrowserRouter>
    )
  }
}

export default App
复制代码

到现在我们的服务端渲染基本已经完成了,启动服务之后看页面,

React SSR 工程化最佳实践(基于 koa 和 context api)

这里我们可以看到服务端确实把渲染好的页面直接吐出来了,而客户端渲染却只得到一个空的html文件,再下载js去加载页面内容,而且由于我用的豆瓣和掘金api,在客户端请求跨域,只有在服务端能拿到数据,这里又发现ssr的另一个好处了~~~而且由于请求是在服务端发的,在页面中是看不到请求的api的。

到这里我们基本已经完成了 基于 context api 的服务端渲染了,但是还有一个遗留的问题,如果我在服务端请求失败,吐出来页面也没有数据该怎么办呢?

所以要针对这种情况做一些特殊的处理。

这里增加了一个 moreFetch 的 hoc,对有异步请求的页面都套上这个 hoc,这个 hoc 的作用是客户端渲染的过程中发现如果没有想要的数据,判定为请求失败,在客户端重新请求一次。

/**
 * 服务端请求失败时 client 端的发请求逻辑
 */
import hoistNonReactStatics from 'hoist-non-react-statics'
import {pick} from 'lodash'
import { withAppContext } from 'hocs/withAppContext'

const defaultOptions = {
  // 在浏览器端 didMount 和 didUpdate 时默认触发个,可以自定义
  client: true,
  // 自动注入获取到的数据至 props 中 ([fetchId], error, pending),指定一个 id
  fetchId: null
}

export default function moreFetch (options = {}) {
  options = { ...defaultOptions, ...options }
  const enableAtClient = options.client
  const { fetchId } = options

  return function moreFetchInner (Component) {

    if (!Component.prototype.getInitialProps) {
      throw new Error(`getInitialProps must be defined`)
    }
    // 注意这里继承的是传入的 Component
    class moreFetchWrapper extends Component {

      constructor(props) {
        super(props)
        this.getInitialProps = this.getInitialProps.bind(this)
      }

      static defaultProps = {
        [fetchId]: {}
      }

      shouldGetInitialProps() {
      
      // 根据 fetchId 下的pending 字段判定服务端是否拉取到数据
        return this.props[fetchId].pending === undefined
      }

      componentDidMount () {
        if (typeof super.componentDidMount === 'function') {
          super.componentDidMount()
        }
        this._triggerAtClient()
      }

      componentDidUpdate (...args) {
        if (typeof super.componentDidUpdate === 'function') {
          super.componentDidUpdate(...args)
        }
        this._triggerAtClient()
      }

      // 客户端同构请求
      _triggerAtClient () {
        if (!enableAtClient) {
          return
        }
        if (typeof this.shouldGetInitialProps === 'function') {
          if (this.shouldGetInitialProps() && typeof this.getInitialProps === 'function') {
            this._trigger()
          }
        }
      }

      // client 的实际请求发送逻辑
      _trigger () {
        this._setContextProps({ pending: true })
        return this.getInitialProps()
          .then(data => {
          
          // 客户端支持动态设置 context,因此这里拿到数据之后就直接塞到 context 里就可以了
            this._setContextProps({ pending: false, data, error: null })
          }, error => {
            this._setContextProps({ pending: false, data: {}, error })
          })
      }

      // 注入数据到 appContext
      _setContextProps (x) {
        if (!fetchId) {
          return
        }

        this.props.setAppContext(appContext => {
          const oldVal = appContext[fetchId] || {}
          const newVal = {[fetchId]: { ...oldVal, ...x }}
          return newVal
        })
      }

      render () {
        return super.render()
      }
    }

    hoistNonReactStatics(moreFetchWrapper, Component)

    return withAppContext(
      function (appContext) {
        const con = pick(appContext, ['setAppContext'])
        return Object.assign(con, (appContext || {})[fetchId])
      }
    )(moreFetchWrapper)
  }
}

复制代码

这个 hoc 有两个作用,一是服务端请求失败发二次请求,保证页面的有效性,第二是当我不做服务端渲染时,依然可以将客户端打包文件部署到线上,默认都会走这个 hoc 的发请求逻辑。这样相当于给上了一层保险。

到这里才算真正做到客户端服务端同构了,项目还需要持续优化~~喜欢的小伙伴点个star吧~~

react-ssr

如果有任何问题,欢迎留言或者提issue,项目有任何需要改进的地方,也欢迎指正~~

另外在用豆瓣和掘金的 api 的时候突发奇想,简单设计了一个自己的 koa 爬虫框架,抓取静态页面或者动态 api ,感兴趣的小伙伴也可以瞄一眼~~

爬虫 firm-spider


以上所述就是小编给大家介绍的《React SSR 工程化最佳实践(基于 koa 和 context api)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

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

遗传算法原理及应用

遗传算法原理及应用

周明、孙树栋 / 国防工业出版社 / 1999-6 / 18.0

一起来看看 《遗传算法原理及应用》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具