详解基于React.js和Node.js的SSR实现方案
基础概念
ssr:即服务端渲染(server side render) 传统的服务端渲染可以使用java,php 等开发语言来实现,随着 node.js 和相关前端领域技术的不断进步,前端同学也可以基于此完成独立的服务端渲染。
过程:浏览器发送请求 -> 服务器运行 react代码生成页面 -> 服务器返回页面 -> 浏览器下载html文档 -> 页面准备就绪 即:当前页面的内容是服务器生成好给到浏览器的。
对应csr:即客户端渲染(client side render) 过程:浏览器发送请求 -> 服务器返回空白 html(html里包含一个root节点和js文件) -> 浏览器下载js文件 -> 浏览器运行react代码 -> 页面准备就绪 即:当前页面的内容是js渲染出来
如何区分页面是否服务端渲染: 右键点击 -> 显示网页源代码,如果页面上的内容在html文档里,是服务端渲染,否则就是客户端渲染。
对比
- csr:首屏渲染时间长,react代码运行在浏览器,消耗的是浏览器的性能
- ssr:首屏渲染时间短,react代码运行在服务器,消耗的是服务器的性能
为什么要用服务端渲染
首屏加载时间优化,由于ssr是直接返回生成好内容的html,而普通的csr是先返回空白的html,再由浏览器动态加载javascript脚本并渲染好后页面才有内容;所以ssr首屏加载更快、减少白屏的时间、用户体验更好。
seo (搜索引擎优化),搜索关键词的时候排名,对大多数搜索引擎,不识别javascript 内容,只识别 html 内容。 (注:原则上可以不用服务端渲染时最好不用,所以如果只有 seo 要求,可以用预渲染等技术去替代)
构建一个服务端渲染的项目
(1) 使用 node.js 作为服务端和客户端的中间层,承担 proxy代理,处理cookie等操作。
(2) hydrate 的使用:在有服务端渲染情况下,使用hydrate代替render,它的作用主要是将相关的事件注水进html页面中(即:让react组件的数据随着html文档一起传递给浏览器网页),这样可以保持服务端数据和浏览器端一致,避免闪屏,使第一次加载体验更高效流畅。
reactdom.hydrate(<app />, document.getelementbyid('root'));
(3) 服务端代码webpack编译:通常会建一个webpack.server.js文件,除了常规的参数配置外,还需要设置target参数为'node'。
const serverconfig = { target: 'node', entry: './src/server/index.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, '../dist') }, externals: [nodeexternals()], module: { rules: [{ test: /\.js?$/, loader: 'babel-loader', exclude: [ path.join(__dirname, './node_modules') ] } ... ] } (此处省略样式打包,代码压缩,运行坏境配置等等...) ... };
(4) 使用react-dom/server下的 rendertostring方法在服务器上把各种复杂的组件和代码转化成 html 字符串返回到浏览器,并在初始请求时发送标记以加快页面加载速度,并允许搜索引擎抓取页面以实现seo目的。
const render = (store, routes, req, context) => { const content = rendertostring(( <provider store={store}> <staticrouter location={req.path} context={context}> <div> {renderroutes(routes)} </div> </staticrouter> </provider> )); return ` <html> <head> <title>ssr</title> </head> <body> <div id='root'>${content}</div> <script src='/index.js'></script> </body> </html> `; } app.get('*', function (req, res) { ... const html = render(store, routes, req, context); res.send(html); });
与rendertostring类似功能的还有: i. rendertostaticmarkup:区别在于rendertostaticmarkup 渲染出的是不带data-reactid的纯html,在javascript加载完成后因为不认识之前服务端渲染的内容导致重新渲染(可能页面会闪一下)。
ii. rendertonodestream:将react元素渲染为其初始html,返回一个输出html字符串的可读流。
iii. rendertostaticnodestream:与rendertonodestream此类似,除了这不会创建react在内部使用的额外dom属性,例如data-reactroot。
(5) 使用redux 承担数据准备,状态维护的职责,通常搭配react-redux, redux-thunk(中间件:发异步请求用到action)使用。(本猿目前使用比较多是就是redux和mobx,这里以redux为例)。 a. 创建store(服务器每次请求都要创建一次,客户端只创建一次):
const reducer = combinereducers({ home: homereducer, page1: page1reducer, page2: page2reducer }); export const getstore = (req) => { return createstore(reducer, applymiddleware(thunk.withextraargument(serveraxios(req)))); } export const getclientstore = () => { return createstore(reducer, window.state_from_server, applymiddleware(thunk.withextraargument(clientaxios))); }
b. action: 负责把数据从应用传到store,是store数据的唯一来源
export const getdata = () => { return (dispatch, getstate, axiosinstance) => { return axiosinstance.get('interfaceurl/xxx') .then((res) => { dispatch({ type: 'home_list', list: res.list }) }); } }
c. reducer:接收旧的state和action,返回新的state,响应actions并发送到store。
export default (state = { list: [] }, action) => { switch(action.type) { case 'home_list': return { ...state, list: action.list } default: return state; } } export default (state = { list: [] }, action) => { switch(action.type) { case 'home_list': return { ...state, list: action.list } default: return state; } }
d. 使用react-redux的connect,provider把组件和store连接起来
provider 将之前创建的store作为prop传给provider
const content = rendertostring(( <provider store={store}> <staticrouter location={req.path} context={context}> <div> {renderroutes(routes)} </div> </staticrouter> </provider> ));
connect([mapstatetoprops],[mapdispatchtoprops],[mergeprops], [options])接收四个参数 常用的是前两个属性 mapstatetoprops函数允许我们将store中的数据作为props绑定到组件上mapdispatchtoprops将action作为props绑定到组件上
connect(mapstatetoprops(),mapdispatchtoprops())(mycomponent)
(6) 使用react-router承担路由职责 服务端路由不同于客户端,它是无状态的。react 提供了一个无状态的组件staticrouter,向staticrouter传递当前url,调用reactdomserver.rendertostring() 就能匹配到路由视图。
服务端
import { staticrouter } from 'react-router-dom'; import { renderroutes } from 'react-router-config' import routes from './router.js' <staticrouter location={req.path} context={{context}}> {renderroutes(routes)} </staticrouter>
浏览器端
import { browserrouter } from 'react-router-dom'; import { renderroutes } from 'react-router-config' import routes from './router.js' <browserrouter> {renderroutes(routes)} </browserrouter>
当浏览器的地址栏发生变化的时候,前端会去匹配路由视图,同时由于req.path发生变化,服务端匹配到路由视图,这样保持了前后端路由视图的一致,在页面刷新时,仍然可以正常显示当前视图。如果只有浏览器端路由,而且是采用browserrouter,当页面地址发生变化后去刷新页面时,由于没有对应的html,会导致页面找不到,但是加了服务端路由后,刷新发生时服务端会返回一个完整的html给客户端,页面仍然正常显示。 推荐使用 react-router-config插件,然后如上代码在staticrouter和browserrouter标签的子元素里加renderroutes(routes):建一个router.js文件
const routes = [{ component: root, routes: [ { path: '/', exact: true, component: home, loaddata: home.loaddata }, { path: '/child/:id', component: child, loaddata: child.loaddata routes: [ path: '/child/:id/grand-child', component: grandchild, loaddata: grandchild.loaddata ] } ] }];
在浏览器端请求一个地址的时候,server.js 里在实际渲染前可以通过matchrouters 这种方式确定要渲染的内容,调用loaderdata函数进行action派发,返回promise->promiseall->rendertostring,最终生成html文档返回。
import { matchroutes } from 'react-router-config' const loadbranchdata = (location) => { const branch = matchroutes(routes, location.pathname) const promises = branch.map(({ route, match }) => { return route.loaddata ? route.loaddata(match) : promise.resolve(null) }) return promise.all(promises) }
(7) 写组件注意代码同构(即:一套react代码在服务端执行一次,在客户端再执行一次) 由于服务器端绑定事件是无效的,所以服务器返回的只有页面样式(&注水的数据),同时返回javascript文件,在浏览器上下载并执行javascript时才能把事件绑上,而我们希望这个过程只需编写一次代码,这个时候就会用到同构,服务端渲染出样式,在客户端执行时绑上事件。
优点: 共用前端代码,节省开发时间 弊端: 由于服务器端和浏览器环境差异,会带来一些问题,如document等对象找不到,dom计算报错,前端渲染和服务端渲染内容不一致等;前端可以做非常复杂的请求合并和延迟处理,但为了同构,所有这些请求都在预先拿到结果才会渲染。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
推荐阅读