详解React 服务端渲染方案完美的解决方案
最近在开发一个服务端渲染工具,通过一篇小文大致介绍下服务端渲染,和服务端渲染的方式方法。在此文后面有两中服务端渲染方式的构思,根据你对服务端渲染的利弊权衡,你会选择哪一种服务端渲染方式呢?
什么是服务器端渲染
使用 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 等资源进行部署。
- 服务器 bundle 用于服务器端渲染(ssr)
- 客户端 bundle 给浏览器加载,浏览器通过 bundle 加载更多其它模块(chunk)js
- 资源映射文件 assets.json 则是,服务器 bundle 在准备所需 html,需要预插入那些模块(chunk)js,和css,这只是提高用户体验。
具体使用方法,可以看我最近造的个* kkt-ssr,这个*将工具的部分封装起来,你只需要写业务代码,和少量的服务端渲染代码即可,还附赠十几个示例,加上一个相对比较完善的示例react-router+rematch
,类似于 next.js,但是有相当大的区别。
第二种方式
这是一种创新的方法,前端单页面应用,以前怎么玩儿,现在还怎么玩儿,多的一步是,你得先访问一个rendora的服务,在前面拦截是否需要服务端渲染。下图为官方图:
这种方式原本只是个想法,想法是前端不用管服务端渲染的事儿了,不就是解决seo?,这些爬虫过来的时候,可以通过头信息判断,写个服务,然后将需要的内容给爬虫就可以了,昨天恰巧在github的趋势榜上,恰巧看到 rendora 个工具,也就那么巧,刚好思路一致,这个工具主要为网络爬虫提供零配置服务器端渲染,以便毫不费力地改进在现代javascript框架(如react.js,vue.js,angular.js等)中开发的网站的seo问题。
这种方式非常好,之前写好的项目一句不用改,只需新起 rendora 服务。对于来自前端服务器或外部的每个请求(百度谷歌爬虫),rendora会根据配置文件,根据头,路径来检测或过滤,以确定 rendora 是否应该只传递从后端服务器返回的初始html或使用chrome提供的无头服务器端呈现的html。更具体地说,对于每个请求,有2条路径:
- 请求被列入白名单作为ssr的候选者(即过滤后的get请求),rendora 会指示无头chrome实例请求相应的页面,呈现它,并返回包含最终服务器端的响应呈现出html。通常只需要将百度、谷歌、必应爬虫等网络抓取工具列入白名单即可。
- 未列入白名单(即请求不是get请求或未通过任何过滤器),rendora将只是充当反向http代理,只是按原样传送请求和响应。
rendora可以看作是位于后端服务器(例如node.js / express.js,python / django等等)之间的反向http代理服务器,也可能是你的前端代理服务器(例如nginx,traefik,apache等),
rendora 是我见过的接近于完美的动态渲染器,提供零配置服务器端渲染
我们到底选择哪一种服务端渲染呢?
rendora,新的方式非常厉害,有很多优势:
- 方便迁移老的项目,前端和后端代码不需要更改。
- 可能更快的性能,资源(cpu)消耗可能更少,golang编写的二进制文件
- 多种缓存策略
- 已经拥有 docker 容器方案
此工具,服务端渲染的页面需要缓存,缓存引发的小问题就是
通过缓存解决,性能问题和调用api两次的问题,服务端渲染,客户端展示渲染,平常调用一次api,现在调用了两次。
被缓存的页面,不能及时清理,比如网站发现用户发了不良信息,需要清理,就需要清理缓存页面了。如果想提高用户体验,浏览器端一些页面需要服务端渲染,这个时候服务端需要请求api,就会有权限问题,或者直接从缓存里面读取的html,到浏览器客户端,可能会有服务端和浏览器端渲染不一致的错误。
如果上面两种方式不在你的考虑范畴之内,那rendora将是你完美的服务端渲染解决方案
总结
感觉我的*好像白写了一样,经过分析发现目前还有一点作用吧,至少解决了不多调用一次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'] }
最终打包结果
部署方案
pm2 start ./server.js -i 0
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。