【第1560期】前端同构渲染的思考与实践
前言
今日早读文章由@蚂蚁保险体验技术授权分享。
正文从这开始~~
开篇
前端同构渲染的相关架构,给我最直观的感受,这是前端渲染最为复杂的一种方案,也是为了追求极致的用户体验不得不去做的一种尝试,虽然 Node.js 的引入赋能了传统前端领域、SEO 优化也不再是个问题,但很明显,这些只是副产品。
问题
上帝为了我们开了一扇窗,同时也会为我们关上一扇门。
我们所知的传统型 SPA,单页面应用,贴近用户端越近,交互越复杂,它的弊端就越明显,在我们享受 JavaScirpt 给我们带来的无刷新体验和组件化带来的开发效率的同时,『白屏』这个随着 SPA 各种优点随之而来的缺点被遗忘,我们拥有菊花方案在 JavaScript 没有将 DOM 构建好之前蒙层,拥有白屏监控方案将真实用户数据上报改进,但并没有触碰到白屏问题的本质,那就是『DOM 的构建者是 JavaScript,而非原生的浏览器』。
<html>
<head><title /></head>
<body>
<div id="root"></div>
<script src="render.js"></script>
</body>
</html>
如上代码,在 SPA 架构中,服务器端直接给出形如这样的 HTML,浏览器在渲染 body#root 这个节点完成之后,页面的绘制区域其实还是空的,直到 render.js 构建好真实的 DOM 结构之后再 append 到 #root上去。此时,首屏展示出来时,必然是 render.js 通过网络请求完毕,然后加上 JavaScript 执行完成之后的。
让我们回到最初的那个前端时代,那时候 JavaScript 还没有那么强大,我们的服务器端全部吐出 HTML 给前端,我们使用 jQuery 解决用户的交互,这种方式虽有很多弊病,但不可否认的是拥有理论上最低白屏时间。
<html>
<head><title /></head>
<body>
<div id="root">
<div class="header">
<img src="logo.png" />
</div>
<div calss="content">
<div class="shopitem">
</div>
</div>
</div>
</body>
</html>
如上代码,在直出的服务器渲染中,浏览器直接拿到最终的 HTML,浏览器通过解析 HTML 之后将 DOM 元素生成而进行渲染。所以相比于 SPA,服务器端渲染从直观上看:
转化 HTML 到 DOM,浏览器原生会比 JavaScript 生成 DOM 的时间短
省去了 SPA 中 JavaScript 的请求与编译时间
解决
Node.js 的出现极大程度的给传统前端赋予了更大的能量,前端的分离也从前期的物理文件的区分转变为职责上的区分,前端开发者从页面仔的噩梦中解脱出来,最重要的是,JavaScript 能在服务器端执行了。在享受这些红利的同时,我们就会不自觉的设想一种方案,它拥有 SPA 的大部分优点,却解决了它大部分的缺点,那就是服务器端输出 HTML,然后由客户端复用该 HTML,继续 SPA 模式,这样岂不是既解决了白屏和 SEO 问题,又继承了无刷新的用户体验和开发的组件化嘛。
嗯,如果这样的话,就会有个一致性的问题。我们必须在浏览器端复用服务器端输出的 HTML 才能避免多套代码的适配,而传统的模板渲染是可行的,只要选择一套同时支持浏览器和 Node.js 的模板引擎就能搞定。我们写好模板, 在 Node.js 准备好数据,然后将数据灌入模板产出 HTML,输出到浏览器之后由客户端 JavaScript 承载交互,搞定。
软件开发中遇到的所有问题,都可以通过增加一层抽象而得以解决
思路到了这里,我们就会发现,『模板』其实是一种抽象层,虽然底层的 HTML 只能跑在浏览器端,但是顶层的模板却能通过模板引擎同时跑在浏览器和服务器端,此为垂直方向,在水平方向上,模板将数据和结构解耦,将数据灌入结构,这种灌入,实际是一锤子买卖,管生不管养。
随着时间的推进,组件化的大潮来了,其核心概念 Virtual DOM 依其声明式和高性能让前端开发者大呼爽爽爽,但究其本质,就是为了解决频繁操作 DOM 而在 HTML 之上做的一层抽象,与模板不同的是,它将数据与结构产生交互,有代表的要数 Facebook 方使用的单项数据流和 Vue 方使用的 MVVM 数据流,大道至简,我们观察函数 UI = F(data), 其中 UI 为最终产出前端界面,data 为数据,F 则为模板结构或者 Virtual DOM,模板的方式是 F 只执行一遍,而组件方式则为每次 data 改变都会再执行一遍。
所以理论上,无论是模板方式还是组件方式,前后端同构的方案都呼之欲出,我们在 Node.js 端获取数据 ,执行 F 函数,得到 HTML输出给浏览器,浏览器 JavaScript 复用 HTML,继续执行 F 函数,等到数据变化,继续执行 F 函数,交互也得到解决,完美~~~
实施
但由于组件化大势所趋,下文将略去模板方案,我们以 Vue 为类比,下图表明其实施思路:
通用代码
由于 F 同时需要在浏览器端和服务器端执行,所以对于整个 Vue App,我们需要同时支持两端,也就是通用代码。所以我们需要将 SPA 架构的代码进行改造:
分为两个入口,分为服务端和客户端,只引入通用代码,然后在不同的环境里调用各自的渲染函数。当然,在客户端 ReactDOM.render 会生成 DOM 结构,而服务器端通过 ReactServer.renderToString 将生成 HTML,需要由 HTTP Server 推给前端,各入口处解决特异的环境问题;
通用代码中不可在不判定执行环境的情况下引用 DOM、调用 window、document 这些浏览器特异和引用 global process 这些服务器端特异的操作,这往往是引起 Node.js 服务出问题的根本原因;
为了兼容两端,在选择库时,需要也同时需要支持两端,比如 axios,lodash 等;
React 和 Vue 都有生命周期,需要区分哪些生命周期是在浏览器中运行,哪些会在服务器端运行,或者是同时运行,如使用 Redux 或者 Vuex 等库,最好在组件上引入 asyncData 钩子进行数据请求,同时供两端使用;
判定不同的执行环境可以通过注入 process.env.EXEC_ENV 来解决,形如:
if (process.env.EXEC_ENV === 'client') {
window.addEventListener(...);
}
if (process.env.EXEC_ENV === 'server') {
}
构建与运行
在使用 webpack 进行构建时,需要将公共 App 部分打包出来,形成公共代码,由服务器端引入执行,而客户端可以引用打包好的公共代码,再用 webpack 引入之后进行特异处理即可;
需要引入 Node.js 中间层,负责请求数据,提供渲染能力,提供 HTTP 服务,由于 HTML 模板需要在服务端引入,CDN 文件需要自行处理;
至于 babel 的使用,可以在浏览器中通用处理,服务端只解决特殊语法,如 jsx,vue template;
新世界
至此,白屏问题问题看起来是解决了,通过把 JavaScript 的渲染逻辑放到 Node.js 端进行,我们加快了首屏出现的时间,但是联想到 Node.js 对前端的赋能,我们或许可以做的更多。
再议首屏
让我们把视角移动的更细致一些,关注『从服务器端输出 HTML』这一部分,其隐藏的含义是我们需要把 App 渲染的所有 HTML 都输出给前端,其实不然,举个栗子:
比如在移动端有一个页面,它有大约 10 屏的高度,如果我们在服务器端全部输出 10 屏其实是有点浪费的,我们可以只输出首屏需要的,从而降低 render 执行时间从而降低 TTFB 时间,让页面更快的到达用户眼前。实践中,一般情况是输出大概快两屏的样子,就能处理所以机型的高度问题,剩下的 8 屏,在浏览器端继续渲染,渐进产出内容,用户无感知。
资源控制
得益于 Node.js 输出 HTML 的另一层含义,就是我们可以直接在首次接触就能感知到客户端,也就有了足够的灵活性,再举个栗子:
有个针对安卓平台和 iOS 平台不同的脚本只要加载,如果在 SPA 情况下,只有等 JavaScript 执行时我们判定 navigation.userAgent 来获知先在是哪个平台,然后在 appendChild 一个 script 到 body,但如果服务端能首次接触就能感知,我们可以在服务端直接拿到 HTTP 请求中的 userAgent 判定平台,根据标识在模板中处理,很显然,这样很稳。
另外,如果有一些特别复杂的计算,服务端可以有更多的办法将数据更快的处理,以避免繁忙无比的浏览器接手。
缓存控制
一般的业务场景下,我们需要在 Node.js 中通过内网将数据获取到,然后通过 render 函数渲染出 HTML(一般需要将数据附带给 HTML 输出以便重复利用),这个时候我们可以通过页面访问地址和生成的 HTML 字符串做缓存策略,在缓存(一般选择 redis 等方案)之后,下次直接将同样的页面直接输出到前端,可大幅提高渲染性能。
但这种方案也有很多限制,因为要考虑页面地址、多平台下、账户是否登录,页面是否需要改动等情况:
页面地址纬度,在不同的地址下,HTML 输出不一致,所以 URL 可作为 key 的元素之一;
未登录态,页面可以直接缓存,如需判定平台特异,需在 Node.js 端进行处理;
已登录态,如果已缓存某一个已登录用户的 HTML,需要将跟登录相关的组件抹去重新换掉,或者直接给予未登录态页面,在客户端进行变更。
挑战
同构渲染看似美好,但其相对传统 SPA 确有着更多挑战:
Node.js
服务器端渲染相对应传统的 Node.js 应用,renderToString 函数不仅 CPU 密集,而且不同的组件对机器资源的要求不尽相同,这就更需要 Node.js 指标的监控、日志的记录、错误的收集、崩溃机制的完善。这里额外的关键的指标是 renderToString 的时间,它反应了 Node.js 渲染所使用的时间,如果加入缓存机制,就需要统计命中率等等。
代码质量
关于写通用代码,要求比 SPA 架构对开发者提出了更高的要求,我们需要小心再小心,因为万一搞错,将导致很难排查的内存泄露和 CPU 飙升,并且一旦出了问题,就像要修理天上跑的飞机一样,非常困难。还记得有一次在类似 componentWillMount 写了一些跟浏览器相关的代码导致的内存飙升,还有一次 JSON.stringify 一个大对象导致的 CPU 飙升,不堪回首。这方面 alinode 做的很好,确实可以满足这种飞机场景。
结语
为了效率, 前端们付出了艰辛的努力,无论是工程上我们千方百计的制造工具,还是组件化的引入,我们解决的是开发的效率,而无论是 Virtual DOM 的引入解决频繁操作的 DOM,还是用了提升用户体验而使用的 SPA 架构,我们解决的是用户的使用效率,是前端的性能。而同构渲染也是这样一种方案,它引入了 Node.js 的复杂度,要求我们写出限制更多的代码,其根本目的还是为了让用户更快更早的看到页面,那怕是 50 毫秒,那怕是 10 毫秒。
关于本文
作者:@蚂蚁保险体验技术
链接:https://juejin.im/post/5c821dc45188257e1f2915b1
@蚂蚁保险体验技术团队,来自蚂蚁金服保险事业群。我们是一个年轻的团队(没有历史技术栈包袱),目前平均年龄92年(去除一个最高分8x年-团队leader,去除一个最低分97年-实习小老弟)。我们支持了阿里集团几乎所有的保险业务。18年我们产出的相互宝轰动保险界,19年我们更有多个重量级项目筹备动员中。现伴随着事业群的高速发展,团队也在迅速扩张,欢迎各位前端高手加入我们~
最后,为你推荐