详解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 相关文章推荐
javascript动态加载实现方法一
Aug 22 Javascript
Javascript Objects详解
Sep 04 Javascript
jquery select2的使用心得(推荐)
Dec 04 Javascript
Vue.js学习之过滤器详解
Jan 22 Javascript
基于cookie实现zTree树刷新后展开状态不变
Feb 28 Javascript
js实现多张图片延迟加载效果
Jul 17 Javascript
详解Angular2表单-模板驱动的表单(Template-Driven Forms)
Aug 04 Javascript
浅谈Angular4实现热加载开发旅程
Sep 08 Javascript
vue使用axios上传文件(FormData)的方法
Apr 14 Javascript
vue eslint简要配置教程详解
Jul 26 Javascript
layui的表单验证支持ajax判断用户名是否重复的实例
Sep 06 Javascript
如何优雅地取消 JavaScript 异步任务
Mar 22 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
20个2014年最优秀的PHP框架回顾
2014/10/22 PHP
PHP实现的分页类定义与用法示例
2017/07/05 PHP
php生成复杂验证码(倾斜,正弦干扰线,黏贴,旋转)
2018/03/12 PHP
javascript 正则替换 replace(regExp, function)用法
2010/05/22 Javascript
JS判断当前日期是否大于某个日期的实现代码
2012/09/02 Javascript
JavaScript 更严格的相等 [译]
2012/09/20 Javascript
Javascript全局变量var与不var的区别深入解析
2013/12/09 Javascript
js获取网页可见区域、正文以及屏幕分辨率的高度
2014/05/15 Javascript
js获取form的方法
2015/05/06 Javascript
Vue.js每天必学之指令系统与自定义指令
2016/09/07 Javascript
Javascript 实现计算器时间功能详解及实例(二)
2017/01/08 Javascript
js链表操作(实例讲解)
2017/08/29 Javascript
vue监听scroll的坑的解决方法
2017/09/07 Javascript
图解NodeJS实现登录注册功能
2019/09/16 NodeJs
layui监听单元格编辑前后交互的例子
2019/09/16 Javascript
JavaScript算法学习之冒泡排序和选择排序
2019/11/02 Javascript
Vue中的this.$options.data()和this.$data用法说明
2020/07/26 Javascript
在vue中axios设置timeout超时的操作
2020/09/04 Javascript
JavaScript实现五子棋小游戏
2020/10/26 Javascript
[08:54]DOTA2-DPC中国联赛 正赛 Aster vs LBZS 选手采访
2021/03/11 DOTA
Python实现配置文件备份的方法
2015/07/30 Python
学习python 之编写简单乘法运算题
2016/02/27 Python
python anaconda 安装 环境变量 升级 以及特殊库安装的方法
2017/06/21 Python
查看django版本的方法分享
2018/05/14 Python
基于python框架Scrapy爬取自己的博客内容过程详解
2019/08/05 Python
python基础 range的用法解析
2019/08/23 Python
python ffmpeg任意提取视频帧的方法
2020/02/21 Python
快速解决jupyter notebook启动需要密码的问题
2020/04/21 Python
python继承threading.Thread实现有返回值的子类实例
2020/05/02 Python
python BeautifulSoup库的安装与使用
2020/12/17 Python
党的群众路线教育实践活动查摆问题自查报告
2014/10/10 职场文书
2014年质检员工作总结
2014/11/18 职场文书
2014年法务工作总结
2014/12/11 职场文书
2015驻村干部工作总结
2015/04/07 职场文书
2020年基层司法所建设情况调研报告
2019/11/30 职场文书
Javascript之datagrid查询详解
2021/09/15 Javascript