详解React 服务端渲染方案完美的解决方案


Posted in Javascript onDecember 14, 2018

最近在开发一个服务端渲染工具,通过一篇小文大致介绍下服务端渲染,和服务端渲染的方式方法。在此文后面有两中服务端渲染方式的构思,根据你对服务端渲染的利弊权衡,你会选择哪一种服务端渲染方式呢?

什么是服务器端渲染

使用 React 构建客户端应用程序,默认情况下,可以在浏览器中输出 React 组件,进行生成 DOM 和操作 DOM。React 也可以在服务端通过 Node.js 转换成 HTML,直接在浏览器端“呈现”处理好的 HTML 字符串,这个过程可以被认为 “同构”,因为应用程序的大部分代码都可以在服务器和客户端上运行。

为什么使用服务器端渲染

与传统 SPA(Single Page Application - 单页应用程序)相比,服务器端渲染(SSR)的优势主要在于:

  • 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
  • 更好的用户体验,对于缓慢的网络情况或运行缓慢的设备,加载完资源浏览器直接呈现,无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的HTML。

服务端渲染的弊端

  • 由于服务端与浏览器客户端环境区别,选择一些开源库需要注意,部分库是无法在服务端执行,比如你有 document、window 等对象获取操作,都会在服务端就会报错,所以在选择的开源库要做甄别。
  • 使用服务端渲染,比如要起一个专门在服务端渲染的服务,与之前,只管客户端所需静态资源不同,你还需要 Node.js 服务端的和运维部署的知识,对你所需要掌握的知识点要求更多
  • 服务器需要更多的负载,在 Node.js 中完成渲染,由于 Node.js 的原因大量的CPU资源会被占用。
  • 下文介绍一种服务端渲染的“操作”,这个新的操作拥有新的问题,比如API请求两次,各种服务端问题,你就无能为力了,因为这个新的工具用Golang写的,你的团队或者是你,需要了解一下Golang,你说气不气人又要多学东西。

服务端渲染两种方式

根据上文介绍对服务端渲染利弊有所了解,我们可以根据利弊权衡取舍,最近在做服务端渲染的项目,找到多种服务端渲染解决方案,大致分为两类。

第一种方式

传统方式服务端渲染,解决用户体验和更好的 SEO,有诸多工具使用这种方式如React的(Next.js)、Vue的(Nuxt.js)等。

有些工具将 webpack 运行在服务端生产环境,实时编译,将编译结果缓存起来,这都还是传统的方式,只不过将 webpack 运行在服务端实时编译,还是开发环境编译预编译好的问题。

我选择了将 webpack 放在开发环境,只做开发打包的功能,打包 客户端 bundle ,
服务端 bundle,资源映射文件 assets.json,CSS 等资源进行部署。

详解React 服务端渲染方案完美的解决方案

  • 服务器 bundle 用于服务器端渲染(SSR)
  • 客户端 bundle 给浏览器加载,浏览器通过 bundle 加载更多其它模块(chunk)js
  • 资源映射文件 assets.json 则是,服务器 bundle 在准备所需 HTML,需要预插入那些模块(chunk)js,和CSS,这只是提高用户体验。

具体使用方法,可以看我最近造的个轮子 kkt-ssr,这个轮子将工具的部分封装起来,你只需要写业务代码,和少量的服务端渲染代码即可,还附赠十几个示例,加上一个相对比较完善的示例react-router+rematch,类似于 next.js,但是有相当大的区别。

第二种方式

这是一种创新的方法,前端单页面应用,以前怎么玩儿,现在还怎么玩儿,多的一步是,你得先访问一个Rendora的服务,在前面拦截是否需要服务端渲染。下图为官方图:

详解React 服务端渲染方案完美的解决方案

这种方式原本只是个想法,想法是前端不用管服务端渲染的事儿了,不就是解决SEO?,这些爬虫过来的时候,可以通过头信息判断,写个服务,然后将需要的内容给爬虫就可以了,昨天恰巧在GitHub的趋势榜上,恰巧看到 Rendora 个工具,也就那么巧,刚好思路一致,这个工具主要为网络爬虫提供零配置服务器端渲染,以便毫不费力地改进在现代Javascript框架(如React.js,Vue.js,Angular.js等)中开发的网站的SEO问题。

详解React 服务端渲染方案完美的解决方案

这种方式非常好,之前写好的项目一句不用改,只需新起 Rendora 服务。对于来自前端服务器或外部的每个请求(百度谷歌爬虫),Rendora会根据配置文件,根据头,路径来检测或过滤,以确定 Rendora 是否应该只传递从后端服务器返回的初始HTML或使用Chrome提供的无头服务器端呈现的HTML。更具体地说,对于每个请求,有2条路径:

  1. 请求被列入白名单作为SSR的候选者(即过滤后的Get请求),Rendora 会指示无头Chrome实例请求相应的页面,呈现它,并返回包含最终服务器端的响应呈现出HTML。通常只需要将百度、谷歌、必应爬虫等网络抓取工具列入白名单即可。
  2. 未列入白名单(即请求不是GET请求或未通过任何过滤器),Rendora将只是充当反向HTTP代理,只是按原样传送请求和响应。

Rendora可以看作是位于后端服务器(例如Node.js / Express.js,Python / Django等等)之间的反向HTTP代理服务器,也可能是你的前端代理服务器(例如nginx,traefik,apache等),

Rendora 是我见过的接近于完美的动态渲染器,提供零配置服务器端渲染

我们到底选择哪一种服务端渲染呢?

Rendora,新的方式非常厉害,有很多优势:

  • 方便迁移老的项目,前端和后端代码不需要更改。
  • 可能更快的性能,资源(CPU)消耗可能更少,Golang编写的二进制文件
  • 多种缓存策略
  • 已经拥有 docker 容器方案

此工具,服务端渲染的页面需要缓存,缓存引发的小问题就是

通过缓存解决,性能问题和调用API两次的问题,服务端渲染,客户端展示渲染,平常调用一次API,现在调用了两次。

被缓存的页面,不能及时清理,比如网站发现用户发了不良信息,需要清理,就需要清理缓存页面了。如果想提高用户体验,浏览器端一些页面需要服务端渲染,这个时候服务端需要请求API,就会有权限问题,或者直接从缓存里面读取的HTML,到浏览器客户端,可能会有服务端和浏览器端渲染不一致的错误。

如果上面两种方式不在你的考虑范畴之内,那Rendora将是你完美的服务端渲染解决方案

总结

感觉我的轮子kkt-ssr 好像白写了一样,经过分析发现目前还有一点作用吧,至少解决了不多调用一次API,和API调用权限问题导致渲染不一致的问题。但是我更推荐Rendora的方式,这将是未来。

补充:

同构方案

这里我们采用React技术体系做同构,由于React本身的设计特点,它是以Virtual DOM的形式保存在内存中,这是服务端渲染的前提。

对于客户端,通过调用ReactDOM.render方法把Virtual DOM转换成真实DOM最后渲染到界面。

import { render } from 'react-dom'
import App from './App'

render(<App />, document.getElementById('root'))

对于服务端,通过调用ReactDOMServer.renderToString方法把Virtual DOM转换成HTML字符串返回给客户端,从而达到服务端渲染的目的。

import { renderToString } from 'react-dom/server'
import App from './App'

async function(ctx) {
  await ctx.render('index', {
    root: renderToString(<App />)
  })
}

状态管理方案

我们选择Redux来管理React组件的非私有组件状态,并配合社区中强大的中间件Devtools、Thunk、Promise等等来扩充应用。当进行服务端渲染时,创建store实例后,还必须把初始状态回传给客户端,客户端拿到初始状态后把它作为预加载状态来创建store实例,否则,客户端上生成的markup与服务端生成的markup不匹配,客户端将不得不再次加载数据,造成没必要的性能消耗。

服务端

import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import App from './App'
import rootReducer from './reducers'

const store = createStore(rootReducer)

async function(ctx) {
  await ctx.render('index', {
    root: renderToString(
      <Provider store={store}>
        <App />
      </Provider>
    ),
    state: store.getState()
  })
}

HTML

<body>
  <div id="root"><%- root %></div>
  <script>
    window.REDUX_STATE = <%- JSON.stringify(state) %>
  </script>
</body>

客户端

import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import App from './App'
import rootReducer from './reducers'

const store = createStore(rootReducer, window.REDUX_STATE)

render(
  <Provider store={store}>
    <App />
  </Provider>, 
  document.getElementById('root')
)

路由方案

客户端路由的好处就不必多说了,客户端可以不依赖服务端,根据hash方式或者调用history API,不同的URL渲染不同的视图,实现无缝的页面切换,用户体验极佳。但服务端渲染不同的地方在于,在渲染之前,必须根据URL正确找到相匹配的组件返回给客户端。

React Router为服务端渲染提供了两个API:

  • - match 在渲染之前根据URL匹配路由组件
  • - RoutingContext 以同步的方式渲染路由组件

服务端

import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { match, RouterContext } from 'react-router'
import rootReducer from './reducers'
import routes from './routes'

const store = createStore(rootReducer)

async function clientRoute(ctx, next) {
  let _renderProps

  match({routes, location: ctx.url}, (error, redirectLocation, renderProps) => {
    _renderProps = renderProps
  })

  if (_renderProps) {
    await ctx.render('index', {
      root: renderToString(
        <Provider store={store}>
          <RouterContext {..._renderProps} />
        </Provider>
      ),
      state: store.getState()
    })
  } else {
    await next()
  }
}

客户端

import { Route, IndexRoute } from 'react-router'
import Common from './Common'
import Home from './Home'
import Explore from './Explore'
import About from './About'

const routes = (
  <Route path="/" component={Common}>
    <IndexRoute component={Home} />
    <Route path="explore" component={Explore} />
    <Route path="about" component={About} />
  </Route>
)

export default routes

静态资源处理方案

在客户端中,我们使用了大量的ES6/7语法,jsx语法,css资源,图片资源,最终通过webpack配合各种loader打包成一个文件最后运行在浏览器环境中。但是在服务端,不支持import、jsx这种语法,并且无法识别对css、image资源后缀的模块引用,那么要怎么处理这些静态资源呢?我们需要借助相关的工具、插件来使得Node.js解析器能够加载并执行这类代码,下面分别为开发环境和产品环境配置两套不同的解决方案。

开发环境

首先引入babel-polyfill这个库来提供regenerator运行时和core-js来模拟全功能ES6环境。

引入babel-register,这是一个require钩子,会自动对require命令所加载的js文件进行实时转码,需要注意的是,这个库只适用于开发环境。

引入css-modules-require-hook,同样是钩子,只针对样式文件,由于我们采用的是CSS Modules方案,并且使用SASS来书写代码,所以需要node-sass这个前置编译器来识别扩展名为.scss的文件,当然你也可以采用LESS的方式,通过这个钩子,自动提取className哈希字符注入到服务端的React组件中。

引入asset-require-hook,来识别图片资源,对小于8K的图片转换成base64字符串,大于8k的图片转换成路径引用。

// Provide custom regenerator runtime and core-js
require('babel-polyfill')

// Javascript required hook
require('babel-register')({presets: ['es2015', 'react', 'stage-0']})

// Css required hook
require('css-modules-require-hook')({
  extensions: ['.scss'],
  preprocessCss: (data, filename) =>
    require('node-sass').renderSync({
      data,
      file: filename
    }).css,
  camelCase: true,
  generateScopedName: '[name]__[local]__[hash:base64:8]'
})

// Image required hook
require('asset-require-hook')({
  extensions: ['jpg', 'png', 'gif', 'webp'],
  limit: 8000
})

产品环境

对于产品环境,我们的做法是使用webpack分别对客户端和服务端代码进行打包。客户端代码打包这里不多说,对于服务端代码,需要指定运行环境为node,并且提供polyfill,设置__filename和__dirname为true,由于是采用CSS Modules,服务端只需获取className,而无需加载样式代码,所以要使用css-loader/locals替代css-loader加载样式文件

// webpack.config.js
{
  target: 'node',
  node: {
    __filename: true,
    __dirname: true
  },
  module: {
    loaders: [{
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel',
      query: {presets: ['es2015', 'react', 'stage-0']}
    }, {
      test: /\.scss$/,
      loaders: [
        'css/locals?modules&camelCase&importLoaders=1&localIdentName=[hash:base64:8]',
        'sass'
      ]
    }, {
      test: /\.(jpg|png|gif|webp)$/,
      loader: 'url?limit=8000'
    }]
  }
}

动态加载方案

对于大型Web应用程序来说,将所有代码打包成一个文件不是一种优雅的做法,特别是对于单页面应用,用户有时候并不想得到其余路由模块的内容,加载全部模块内容,不仅增加用户等待时间,而且会增加服务器负荷。Webpack提供一个功能可以拆分模块,每一个模块称为chunk,这个功能叫做Code Splitting。你可以在你的代码库中定义分割点,调用require.ensure,实现按需加载,而对于服务端渲染,require.ensure是不存在的,因此需要判断运行环境,提供钩子函数。

重构后的路由模块为

// Hook for server
if (typeof require.ensure !== 'function') {
  require.ensure = function(dependencies, callback) {
    callback(require)
  }
}

const routes = {
  childRoutes: [{
    path: '/',
    component: require('./common/containers/Root').default,
    indexRoute: {
      getComponent(nextState, callback) {
        require.ensure([], require => {
          callback(null, require('./home/containers/App').default)
        }, 'home')
      }
    },
    childRoutes: [{
      path: 'explore',
      getComponent(nextState, callback) {
        require.ensure([], require => {
          callback(null, require('./explore/containers/App').default)
        }, 'explore')
      }
    }, {
      path: 'about',
      getComponent(nextState, callback) {
        require.ensure([], require => {
          callback(null, require('./about/containers/App').default)
        }, 'about')
      }
    }]
  }]
}

export default routes

优化方案

vendor: ['react', 'react-dom', 'redux', 'react-redux']

所有js模块以chunkhash方式命名

output: {
  filename: '[name].[chunkhash:8].js',
  chunkFilename: 'chunk.[name].[chunkhash:8].js',
}

提取公共模块,manifest文件起过渡作用

new webpack.optimize.CommonsChunkPlugin({
  names: ['vendor', 'manifest'],
  filename: '[name].[chunkhash:8].js'
})

提取css文件,以contenthash方式命名

new ExtractTextPlugin('[name].[contenthash:8].css')

模块排序、去重、压缩

new webpack.optimize.OccurrenceOrderPlugin(), // webpack2 已移除
new webpack.optimize.DedupePlugin(), // webpack2 已移除
new webpack.optimize.UglifyJsPlugin({
  compress: {warnings: false},
  comments: false
})

使用babel-plugin-transform-runtime取代babel-polyfill,可节省大量文件体积
需要注意的是,你不能使用最新的内置实例方法,例如数组的includes方法

{
  presets: ['es2015', 'react', 'stage-0'],
  plugins: ['transform-runtime']
}

最终打包结果

详解React 服务端渲染方案完美的解决方案

部署方案

pm2 start ./server.js -i 0

详解React 服务端渲染方案完美的解决方案

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
js 判断 enter 事件
Feb 12 Javascript
jquery阻止冒泡事件使用模拟事件
Sep 06 Javascript
js 通过html()及text()方法获取并设置p标签的显示值
May 14 Javascript
node.js中的fs.rmdirSync方法使用说明
Dec 16 Javascript
JavaScript实现打开链接页面的方式汇总
Jun 02 Javascript
手机图片预览插件photoswipe.js使用总结
Aug 25 Javascript
Easyui使用Dialog行内按钮布局的实例
Jul 27 Javascript
JS遍历JSON数组及获取JSON数组长度操作示例【测试可用】
Dec 12 Javascript
解决前后端分离 vue+springboot 跨域 session+cookie失效问题
May 13 Javascript
Layui事件监听的实现(表单和数据表格)
Oct 17 Javascript
vuex实现数据状态持久化
Nov 11 Javascript
JS数组去重详情
Nov 07 Javascript
JS/HTML5游戏常用算法之路径搜索算法 A*寻路算法完整实例
Dec 14 #Javascript
JS实现的A*寻路算法详解
Dec 14 #Javascript
详解vue项目接入微信JSSDK的坑
Dec 14 #Javascript
微信小程序实现动态显示和隐藏某个控件功能示例
Dec 14 #Javascript
javascript中的event loop事件循环详解
Dec 14 #Javascript
如何在Vue中使用CleaveJS格式化你的输入内容
Dec 14 #Javascript
webpack配置proxyTable时pathRewrite无效的解决方法
Dec 13 #Javascript
You might like
全国FM电台频率大全 - 12 安徽省
2020/03/11 无线电
php中处理模拟rewrite 效果
2006/12/09 PHP
PHP插入排序实现代码
2013/04/04 PHP
利用PHP生成CSV文件简单示例
2016/12/21 PHP
通过JAVASCRIPT读取ASP设定的COOKIE
2007/02/15 Javascript
Javascript &amp; DHTML 实例编程(教程)(三)初级实例篇1—上传文件控件实例
2007/06/02 Javascript
textarea中的手动换行处理的jquery代码
2011/02/26 Javascript
JQuery EasyUI 日期控件如何控制日期选择区间
2014/05/05 Javascript
JQuery的ON()方法支持的所有事件罗列
2015/02/28 Javascript
在JS方法中返回多个值的方法汇总
2015/05/20 Javascript
轻松实现jquery手风琴效果
2016/01/14 Javascript
jQuery实现按比例缩放图片的方法
2017/04/29 jQuery
基于dataset的使用和图片延时加载的实现方法
2017/12/11 Javascript
解决layui的table插件无法多层级获取json数据的问题
2019/09/19 Javascript
微信小程序实现音频文件播放进度的实例代码
2020/03/02 Javascript
如何编写一个 Webpack Loader的实现
2020/10/18 Javascript
python编写简单爬虫资料汇总
2016/03/22 Python
Python 记录日志的灵活性和可配置性介绍
2018/02/27 Python
解决python中遇到字典里key值为None的情况,取不出来的问题
2018/10/17 Python
Python获取数据库数据并保存在excel表格中的方法
2019/06/12 Python
使用Python代码实现Linux中的ls遍历目录命令的实例代码
2019/09/07 Python
武汉英思工程科技有限公司&ndash;ORACLE面试测试题目
2012/04/30 面试题
会计系毕业个人自荐信格式
2013/09/23 职场文书
团员学习总结的自我评价范文
2013/10/14 职场文书
行政总经理岗位职责
2013/12/05 职场文书
大学生职业生涯规划书范文
2014/01/14 职场文书
铁路安全事故反思
2014/04/26 职场文书
主题团日活动总结
2014/06/25 职场文书
政协会议宣传标语
2014/10/09 职场文书
公务员政审个人总结
2015/02/12 职场文书
财务统计员岗位职责
2015/04/14 职场文书
2015年司法所工作总结
2015/04/27 职场文书
入党积极分子党支部意见
2015/06/02 职场文书
导游词之上海豫园
2019/10/24 职场文书
Java 在线考试云平台的实现
2021/11/23 Java/Android
SQL Server远程连接的设置步骤(图文)
2022/03/23 SQL Server