React在中后台业务里已经很好落地了,但对于C端(给用户使用的端,比如PC/H5)业务有其特殊性,对性能要求比较苛刻,且有SEO需求。另外团队层面也希望能够统一技术栈,小伙伴们希望成长,那么如何能够完成既要、也要、还要呢?
\n本次分享主要围绕C端业务下得React SSR实践,会讲解各种SSR方案,包括Next.js同构开发,并以一次优化的过程作为实例进行讲解。其实这些铺垫都是在工作中做的Web框架的设计而衍生出来的总结。这里先卖个关子,自研框架基于Umi框架并支持SSR的相关内容留到广州QCon上讲,感兴趣的同学可以来5月的QCon全球软件开发大会广州站聊。下面开始正题。
\n曾和小弟讨论什么是SSR?他开始以为React SSR就是SSR,这是不完全对的,忽略了Server-side Render的本质。其实从早期的cgi,到PHP、ASP,jsp等server page,这些动态网页技术都是服务器端渲染的。而React SSR更多是强调基于React技术栈进行的服务器端渲染,是服务器端渲染的分类之一,本文会以React SSR为主进行讲解。
\n1、为什么要上SSR?
\n对于SSR,大家的认知大概是以下3个方面。
\n• SEO:强需求,被搜索引擎收录是网站的基本能力。
\n • C端性能:至少要保证首屏渲染效率,如果秒开率都无法保证,那么用户体验是极差的。
\n • 统一技术栈:目前团队以React为主,无论从团队成长,还是个人成长角度,统一技术栈的好处都是非常明显的。
诚然,以上都是大家想用SSR的原因,但对笔者来说,SSR的意义远不止如此。在技术架构升级的过程中,如果能够同时带给团队和小伙伴成长,才是两全其美的选择。目前我负责优酷PC/H5业务,在优酷落地Node.js,目前在做React SSR相关整合工作。玉伯曾讲过在All in Mobile的时代的尴尬——对于多端来说是毁灭性的灾难。\u0008押宝移动端在当时是正确的选择,但在今天获客成本过高,且移动端增速不足,最好的选择就是多端在产品细节上做
\nPK,PC/H5业务的生机也正在于此。
然而历史包袱如此的重,有几方面原因。1)页面年久失修;2)移动端在All in Mobile时代并没有给多端提供技术支持,PC/H5是掉队的,需要补齐App端的基本能力;3)技术栈老旧,很多页面还是采用jQuery开发的,对于团队来说,这才是最痛苦的事儿。
\n其实所有公司都是类似的,都是用有限资源做事,希望最少的投入带来最大化的产出。可以说,通过整合SSR一举三得,将Node.js和React一同落地,顺便将基础框架也落地升级,这样的投入产出是比较高的。
\n2、从CSR到SSR演进之路
\nSSR看起来很简单,如果细分一下,还是略微负责的,下面和我一起看一下从CSR到SSR演进之路。
\n客户端渲染 (CSR)
\n客户端渲染是目前最简单的开发方式,以React为例,CSR里所有逻辑,数据获取、模板编译、路由等都是在浏览器做的。
\nWebpack在工程化与构建方便提供了足够多便利,除了提供Loader和Plugin机制外,还将所有构建相关步骤都进行了封装,甚至连模块按需加载都内置,还具备Tree-shaking等能力,外加Node cluster利用多核并行构建。很明显这是非常方便的,对前端意义重大的。开发者只需要关注业务模块即可。
\n常见做法是本地通过Webpack打包出bundle.js,嵌入到简单的HTML模板里,然后将HTML和bundle.js都发布到CDN上。这样开发方式是目前最常见的,对于做一些内部使用的管理系统是够的。
\nCSR缺点也是非常明显的,首屏性能无法保障,毕竟React全家桶基础库就很大,外加业务模块,纵使按需加载,依然很难保证秒开的。
\n为了优化CSR性能,业界有很多最佳实践。在2018年,笔者以为React最成功的项目是CRA(create-react-app),支付宝开发的Umi其实也是类似的。他们通过内置Webpack和常见Webpack中间件,解决了Webpack过于分散的问题。通过约定目录,统一开发者的开发习惯。
\n与此同时,也产生了很多与时俱进的最佳实践。使用react-router结合react-loadable,更优雅的做dynamic import。在页面中切换路由时按需加载,在Webpack中做过代码分割,这是极好的实践。
\n以前是打包bundle是非常大的,现在以路由为切分标准,按需加载,效率自然是高的。
\nUmi基于react-router又进步增强了,约定页面有布局的概念。
\nexport default {\n routes: [\n { path: '/', component: './a' },\n { path: '/list', component: './b', Routes: ['./routes/PrivateRoute.js'] },\n { path: '/users', component: './users/_layout',\n routes: [\n { path: '/users/detail', component: './users/detail' },\n { path: '/users/:id', component: './users/id' }\n ]\n },\n ],\n};\n
\n
这样做的好处,就有点模板引擎中include类似的效果。布局提效也是极其明显的。为了演示优化后的效果,这里以Umi为例。它首先会加载index页面,找到index布局,先加载布局,然后再加载index页面里的组件。下图加了断点,你可以很清楚的看出加载过程。
\n在 create-react-app(cra)和Umi类似,都是通过约定,隐藏具体实现细节,让开发者不需要关注构建。在未来,类似的封装还会有更多的封装,偏于应用层面。笔者以为前端开发成本在降低,未来有可能规模化的,因为框架一旦稳定,就有大量培训跟进,导致规模化开发。这是把双刃剑,能满足企业开发和招人的问题,但也在创新探索领域上了枷锁。
\n预渲染(Prerending)
\nSPA(单页面应用)的主要内容都依赖于JavaScript(bundle.js)的执行,当首页HTML下载下来的时候,并不是完整的页面,而是浏览器里加载HTML并JavaScript文件才能完成渲染。用户在访问的时候体验会很好,但是对于搜索引擎是不好收录的,因为它们不能执行JavaScript,这种场景下预渲染(Prerending)就派上用场了,它可以帮忙把页面渲染完成之后再返回给爬虫工具,我们的页面也就能被解析到了。
\nCSR是由bundle.js来控制渲染的,所以它外层的HTML都很薄。对于首屏渲染来说,如果能够先展示一部分布局内容,然后在走CSR的其他加载,效果会更好。另外业内有太多类似的事件了,比如用less写css,coffee写js,用markdown写博客,都是需要编译一次才能使用的。比如Jekyll/Hexo等著名项目,它们都非常好用。那么基于React技术,也必然会做预处理的,Gatsby/Next.js都有类似的功能。将React组建编译成HTML,可以编译全部,也可以只编译布局,对于页面性能来说,预渲染是非常简单的提升手段。其原理JSX模板和Webpack stats结合,进行预编译。
\n• 编译全部:纯静态页面。
\n • 只编译布局:对于SPA类项目是非常好,当然多页应用也可以只编译布局的。
生成纯HTML,可以直接放到CDN上,这是简单的静态渲染。如果不直接生成HTML,由Node.js来接管,那么就可以转换为简单的SSR。
\n无论CSR还是静态渲染,都不得不面对数据获取问题。如果bundle.js加载完成,Ajax再获取的话,整个过程还要增加50ms以上的交互时间。如果预先能够得到数据,肯定是更好的。
\n类似上图中的数据,放在Node.js层去获取,并注入到页面,是服务器端渲染最常用的手段,当然,服务器端远不止这么简单。
\n服务器端(SSR)
\n纯服务器渲染其实很简单,就是服务器向浏览器写入HTML。典型的CGI或ASP、PHP、JSP这些都算,其核心原理就是模板+数据,最终编译为HTML并写入到浏览器。
\n第一种方式是直接将HTML写入到浏览器,具体如下。
\n上图中的renderToString是react SSR的API,可以理解成将React组件编译成HTML字符串,通俗点,可以理解React就是当模板使用。在服务器向浏览器写入的第一个字节,就是TTFB时间,然后网络传输时间,然后浏览器渲染,一般关注首屏渲染。如果一次将所有HTML写入到浏览器,可能会比较大,在编译React组件和网络传输时间上会比较长,渲染时间也会拉长。
\n第二种方式是就采用Bigpipe进行分块传输,虽然Bigpipe是一个相对比较”古老“的技术,但在实战中还是非常好用的。在Node.js里,默认res.write就支持分块传输,所以使用Node.js做Bigpipe是非常合适的,在去哪儿的PC业务里就大量使用这种方式。
\n以上2种方法都是服务器渲染,在没有客户端bundle.js助力的情况下,第一种情况除了首屏后懒加载外,客户端能做的事儿不多。第二种情况下,还是有手段可以用的,比如在分块里写入脚本,可以做的的事情还是很多的。
\n res.write(\u0026quot;\u0026lt;script\u0026gt;alert('something')\u0026lt;/script\u0026gt;\u0026quot;)\n
\n
渐进混搭法(Progressive Rehydration)
\n渐进混搭法是将CSR和SSR一起使用的方式。SSR负责接口请求和首屏渲染,并客户端准备数据或配合完成某些生命周期的操作。
\n首先,在服务器端生成布局文件,用于首屏渲染,在布局文件里会嵌入bundle.js。当页面加载bundle.js成功后,客户端渲染就开始了。通常客户端渲染过程都会在domReady之前,所以优化效果是极其明显的。
\nBigpipe可以使用在分块里写入脚本,在React SSR里也可以使用renderToNodeStream搞定。React 16现在支持直接渲染到节点流。渲染到流可以减少你的内容的第一个字节(TTFB)的时间,在文档的下一部分生成之前,将文档的开头至结尾发送到浏览器。当内容从服务器流式传输时,浏览器将开始解析HTML文档。渲染到流的另一个好处是能够响应。 实际上,这意味着如果网络被备份并且不能接受更多的字节,则渲染器会获得信号并暂停渲染,直到堵塞清除。这意味着您的服务器使用更少的内存,并更加适应I / O条件,这两者都可以帮助您的服务器处于具有挑战性的条件。
\n最简单的示例,你只需要stream.pipe(res, { end: false })。
\n// 服务器端\n// using Express\nimport { renderToNodeStream } from \u0026quot;react-dom/server\u0026quot;\nimport MyPage from \u0026quot;./MyPage\u0026quot;\napp.get(\u0026quot;/\u0026quot;, (req, res) =\u0026gt; {\n res.write(\u0026quot;\u0026lt;!DOCTYPE HTML\u0026gt;\u0026lt;HTML\u0026gt;\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;My Page\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt;\u0026lt;body\u0026gt;\u0026quot;);\n res.write(\u0026quot;\u0026lt;div id='content'\u0026gt;\u0026quot;); \n const stream = renderToNodeStream(\u0026lt;MyPage/\u0026gt;);\n stream.pipe(res, { end: false });\n stream.on('end', () =\u0026gt; {\n res.write(\u0026quot;\u0026lt;/div\u0026gt;\u0026lt;/body\u0026gt;\u0026lt;/HTML\u0026gt;\u0026quot;);\n res.end();\n });\n});\n
\n
当MyPage组件的HTML片段写到浏览器里,你需要通过hydrate进行绑定。
\n// 浏览器端\nimport { hydrate } from \u0026quot;react-dom\u0026quot;\nimport MyPage from \u0026quot;./MyPage\u0026quot;\nhydrate(\u0026lt;MyPage/\u0026gt;, document.getElementById(\u0026quot;content\u0026quot;))\n
\n
至此,你大概能够了解React SSR的原理了。服务器编译后的组件更多的是偏于HTML模板,而具体事件和vdom操作需要依赖前端bundle.js做,即前端hydrate时需要做的事儿。
\n可是,如果有多个组件,需要写入多次流呢?使用renderToString就简单很多,普通模板的方式,流却使得这种玩法变得很麻烦。
React SSR里还有一个新增API:renderToNodeStream,结合Stream也能实现Bigpipe一样的效果,而且可以有效的提高TTFB时间。
\n伪代码
\nconst stream1 = renderToNodeStream(\u0026lt;MyPage/\u0026gt;);\nconst stream2 = renderToNodeStream(\u0026lt;MyTab/\u0026gt;);\n\nres.write(stream1)\nres.write(stream2)\nres.end()\n
\n
如果每个React组件都用renderToNodeStream编译,并写入浏览器,那么流的优势就极其明显了,边读边写,都是内存操作,效率非常高。后端写入一个React组件,前端就hydrate绑定一下,如此循环往复,其做法和Bigpipe如出一辙。
\nNext.js同构开发
\nNode.js成熟的标志是以MEAN架构开始替换LAMP。在MEAN之后,很多关于同构的探索层出不穷,比如Meteor,将同构进程的非常彻底,使用JavaScript搞定前后端,开创性的提出了Realtime、Date on the Wire、Database Everywhere、Latency Compensation,零部署等特性,其核心还是围绕Full Stack Reactivity做的,这里不展开。简言之,当数据发生改变的时候,所有依赖该数据的地方自动发生相应的改变。本身这些概念是很牛的,参与的开发者也都很牛,但问题是过于超前了。熟悉Node.js又熟悉前端的人那时候还没那么多,所以前期开发是非常快的,但一旦遇到问题,调试和解决的成本高,过程是非常难受的。所以至今发布了Meteor 1.8也是不温不火的情况。
\nNext.js是一个轻量级的React应用框架。这里需要强调一下,它不只是React服务端渲染框架。它几乎覆盖了CSR和SSR的绝大部分场景。Next.js自己实现的路由,然后react-loadable进行按照路由进行代码分割,整体效果是非常不错的。Next.js约定组件写法,在React组件上,增加静态的getInitialProps方法,用于API请求处理之用。这样做,相当于将API和渲染分开,API获得的结果作为props传给React组件,可以说,这种设计确实很赞,可圈可点。
\nNextjs式的一键开启CSR和SSR,比如下面这段代码。
\nimport React from 'react'\nimport Link from 'next/link'\nimport 'isomorphic-unfetch'\n\nexport default class Index extends React.Component {\n static async getInitialProps () {\n // eslint-disable-next-line no-undef\n const res = await fetch('https://api.github.com/repos/zeit/next.js')\n const json = await res.json()\n return { stars: json.stargazers_count }\n }\n\n render () {\n return (\n\n \u0026lt;div\u0026gt;\n \u0026lt;p\u0026gt;Next.js has {this.props.stars} \u0026lt;/p\u0026gt;\n \u0026lt;Link prefetch href='/preact'\u0026gt;\n \u0026lt;a\u0026gt;How about preact?\u0026lt;/a\u0026gt;\n \u0026lt;/Link\u0026gt;\n \u0026lt;/div\u0026gt;\n\n )\n }\n}\n\n\n
\n
在scr/pages/*.js都是遵守文件名即path的做法。内部使用react-router封装。在执行过程中
\n- \n
- loadGetInitialProps(),获得执行getInitialProps静态方法的返回值props\n
- 将props传给src/pages/*.js里标准react组件的props\n
优点
\n- \n
- 静态方法,不用创建对象即可直接执行。\n
- 利用组建自身的props传值,与状态无关,简单方便。\n
- SSR和CSR代码是一份,便于维护\n
Next.js的做法成为行业最佳实践并不为过,通过简单的用法,可有效的提高首屏渲染,但对于复杂度较高的情况是很难覆盖的。毕竟页面里用到的API不会那么理想,后端支持力度也是有限的,另外前端自己组合API并不是每个团队都有这样的能力,那么要解此种情况就只有2个选择:1)在SSR里实现,2)自建API中间层。
\n自建API中间层是最好的方式,但如果不方便,集成在SSR里也是可以的。利用Bigpipe和React做好SSR组合,能够完成更强大的能力。限于篇幅,具体实践留在QCon全球软件开发大会(广州站)上分享吧。
\n3、性能问题
\n用SSR最大的问题是场景区分,如果区分不好,还是非常容易有性能问题的。上面5种渲染方式里,预渲染里可以使用服务器端路由,此时无任何问题,就当普通的静态托管服务就好,如果在递进一点,你可以把它理解成是Web模板渲染。这里重点讲一下混搭法和纯SSR。
\n混搭法通常只有简单请求,能玩的事情有限。一般是Node.js请求接口,然后渲染首屏,在正常情况性能很好的,TTFB很好,整体rt也很短,使用于简单的场景。此时最怕API组装,如果是几个API组合在一起,然后在返回首屏,就会导致rt很长,性能下降的非常明显。当然,也可以解,你需要加缓存策略,减少不必要的网络请求,将结果放到Redis里。另外将一些个性化需求,比如千人千面的推荐放到页面中做懒加载。
\n如果是纯服务器渲染,那么要求会更加苛刻,有时rt有10几秒,甚至更长,此时要保证QPS还是有很大难度的。除了合并接口,对接口进行缓存,还能做的就是对页面模块进行分级处理,从布局,核心展示模块,以及其他模块。
\n除了上面这些业务方法外,剩下的就是Node.js自身的性能调优了。比如内存溢出,耗时函数定位等,cpu采样等,推荐使用成熟的alinode和node-clinic。毕竟Node.js专项性能调优模块过多,不如直接用这种套装方案。
\n4、未来
\nNode.js在大前端布局里意义重大,除了基本构建和Web服务外,这里我还想讲2点。首先它打破了原有的前端边界,之前应用开发只分前端和API开发。但通过引入Node.js做BFF这样的API Proxy中间层,使API开发也成了前端的工作范围,让后端同学专注于开发RPC服务,很明显这样明确的分工是极好的。其次,在前端开发过程中,有很多问题不依赖服务器端是做不到的,比如场景的性能优化,在使用React后,导致bundle过大,首屏渲染时间过长,而且存在SEO问题,这时候使用Node.js做SSR就是非常好的。
\n当然,前端开发使用Node.js还是存在一些成本,要了解运维等技能,会略微复杂一些,不过也有解决方案,比如Servlerless就可以降级运维成本,又能完成前端开发。直白点讲,在已有Node.js拓展的边界内,降级运维成本,提高开发的灵活性,这一定会是一个大趋势。
\n未来,API Proxy层和SSR都真正的落在Servlerless,对于前端的演进会更上一层楼。向前是SSR渲染,先后是API包装,攻防兼备,提效利器,自然是趋势。
\n作者简介
\n狼叔(网名i5ting),现为阿里巴巴前端技术专家,Node.js 技术布道者,Node全栈公众号运营者,曾就职于去哪儿、新浪、网秦,做过前端、后端、数据分析,是一名全栈技术的实践者。目前负责BU的Node.js和基础框架开发,即将出版Node.js《狼书》3卷。同时,狼叔将作为QCon全球软件开发大会(广州站)的讲师,分享「C端服务端渲染(SSR)和性能优化实践」,感兴趣的同学可以关注下。
\n