关于京东PLUS会员项目前端性能优化的若干思考

京东PLUS会员项目是国内第一个电商付费会员项目,正式开通的会员数量已破千万。我团队2016年开始接手这个项目的前端开发工作,一路见证了它的高速成长,也为此贡献了自己的力量。

这个项目有几个特点:

第一,需求多。移动端使用 H5 开发,曾有人问为什么不用原生或者 RN 开发? 我觉得吧,以这个项目的需求数量和迭代速度来看,连 H5 都难以 hold 的住,还是不要奢望原生和 RN 了。

第二,产品经理多。一般的项目对接一两个产品经理,这个项目我们需要对接一个异地的产品经理“团队”;一般的项目换产品经理一个一个的换,这个项目一批一批的换……我们已经送走好几届PLUS会员产品经理了。铁打的研发,流水的产品经理。

所以说,PLUS会员项目是业务方滴,也是项目经理滴,还是产品经理滴,但终归是俺们研发滴。每念及此,我的耳边总会响起叶倩文的那首老歌:“天地悠悠,过客匆匆,潮起又潮落…”。

书归正传。用户众多和需求迭代频繁,确保线上安全稳定始终是第一要务。所以在架构调整和性能优化方面我们一直都小心翼翼,以一些小修小补为主,只有到改版的时候才会有大的升级改造。不过,平时我们对这些问题的思考和实践却不曾停止过,我们验证了一些行之有效的优化方案,在下一波改版中将会得到应用。

我虽然不完全认同“前端开发每十八个月难度翻一倍”的说法,但这一行发展迭代速度快却是不争的事实。若等到这些优化方案全都应用上再出来念叨,可能就显得不那么新鲜了。所以,我决定先把这些方案拿出来分享,和感兴趣的小伙伴一起讨论,进一步完善。

这些方案主要针对移动端,优化核心方向是提高首页的加载速度,特别是首屏和弱网络环境下的加载速度。从持久化缓存、削减代码量、优化接口请求、提升主观感受等方面下手,比较大的改动是应用 PWA 和升级架构。PWA 离线缓存可以极大的提升用户体验,不过它对于首次加载速度并无提升作用,还得靠其他优化手段,这是一套组合拳。我们先从架构升级说起吧。

架构升级

项目计划迁移到 Gaea 4.0 脚手架[1],这是我们团队基于 webpack 4 开发的一套通用 Vue 单页面应用脚手架,此前的系列版本已经过数十个项目的验证,还是比较稳定的。近期新推出4.0版相较之前版本有着不小的改进。

  • webpack 升级到了 4.0
  • Babel 升级到了 7.0
  • Vue-loader 升级到了 15
  • 重构了上传插件,一键上传到测试服务器更快更稳定
  • 针对我厂手机和电脑位于不同局域网无法互访的问题,集成了自主研发的 Carefree 解决方案[2],方便真机测试调试
  • 集成了 NutUI 组件库[3],可按需加载需要的UI组件
  • 集成了自主研发的基于swagger的数据mock工具SMOCK[4]
  • 支持自动生成骨架屏[5]
  • 支持 PWA

迁移有几个主要目的:

首先,实现本项目的 webpack 构建工具升级到 4.0,之前是基于 webpack 2.0 开发的,webpack4 有不少提升,比如:

  • Scope Hoisting(作用域提升,webpack3加入),通过减少闭包函数数量加快JS的执行速度
  • 生产环境构建体积更小
  • 开发环境通过优化的增量构建机制提升构建速度,同时提供详细的错误和提示

其次,Gaea 4.0 的 Babel 是 7.0 版的,基于 Babel7 可以实现更智能的 Babel polyfill 按需加载。

再次,本次优化计划尝试的PWA、骨架屏等方案,Gaea 4.0都可以给予基础支持。

最后,Gaea 4.0 集成的Carefree、新的上传插件等功能将给未来的开发和真机调试带来方便。

Babel polyfill的按需加载

如今的 web 应用开发都是在本地进行构建,所以有条件在构建阶段把高版本的 JS 代码编译成低版本语法,这样既使用了新语法,又解决了低版本浏览器的兼容问题。承担这种转换工作的最知名的工具当属 Babel 了。而一直以来,Babel 有个饱受诟病的地方,那就是 polyfill 问题。

Babel 默认只转换 JavaScript 语法,而不转换新的 API,比如 Promise、Generator、Set、Maps、Symbol 等全局对象,一些定义在全局对象上的方法(比如 Object.assign)也不会被转码。如果想让未转码的 API 可在低版本环境正常运行,这就需要使用 polyfill。

polyfill 有多种方案,各有各的问题。目前应用中通常使用 babel-polyfill 方案,而第三方库中通常使用 babel-runtime 和 babel-plugin-transform-runtime 方案。

babel-polyfill 提供完整的环境垫片,包含所有 API 的降级模块,可以为新的 API 和全局对象上的方法提供兜底,其主要缺点是文件较大,压缩后大概八九十KB。目前项目中采用这种方案,这次考虑予以优化,减少加载的代码体积。

如上文提到,这一波改造会把项目迁移到 Gaea 4.0 脚手架中,新脚手架的 Babel 已经升级到了最新的 7.0 版。Babel7 是 Babel6 推出近三年之后发布的一个断崖式升级的大版本,包含很多新特性,其中一个引人关注的特性就是支持更智能的按需加载 polyfill。

Babel7 主要是通过其提供的 @babel/preset-env 实现按需加载的。

使用 @babel/preset-env 也需要首先安装 @babel/polyfill,但最终打出的包并不会导入全部 polyfill。

npm install @babel/polyfill --save

同时,需要在 .browserslistrc 文件或者 .babelrc 的 targets 字段中指定需要兼容的浏览器范围。

之后在.babelrc文件中对 @babel/preset-env 进行配置。

@babel/preset-env 与按需加载 polyfill 相关的选项是 useBuiltIns,它有两个值需要重点关注: entryusage

当值为 entry 时,Babel 会将 import "@babel/polyfill" 或者 require("@babel/polyfill") 语句根据我们指定的环境配置替换为单个的 polyfill require。

如将

import "@babel/polyfill";

替换为

import "core-js/modules/es7.string.pad-start";
import "core-js/modules/es7.string.pad-end";

当值为 usage 时,更加智能。Babel 会根据每个文件的需要和指定的环境配置添加特定的 polyfill,更牛×的是一个 bundle 中相同的 polyfill 只会加载一次,这也有助于减小 bundle 的体积。推测 Babel 是通过对文件进行静态分析实现的这种精准的按需加载 polyfill 功能。

var a = new Promise();

转换后(如果指定的环境不支持)

import "core-js/modules/es6.promise";
var a = new Promise();

转换后(如果指定的环境支持)

var a = new Promise();

我们尝试了一下,先指定需要兼容的浏览器范围,然后安装 @babel/polyfill 并将 @babel/preset-envuseBuiltIns 选项值设为 usage。这样 Babel 就会自动分析每一个文件并在考虑我们指定的浏览器兼容范围的情况下,为每个文件加载其需要的 polyfill。最终项目里只引入了部分 polyfill,经测算,打包后的代码(min)较直接引入完整 babel-polyfill 的方案小60多KB,同时还避免了全局变量污染。

在 Babel 的配置中开启 Debug 模式,构建的时候可以看到每个文件中添加了哪些 polyfill:


(有从知乎远道而来的杠精问到:“这都什么年代了,还在兼容Android 4.0和iOS 8.0?” 我叹口气、耸耸肩,与该杠精握握手…)

关于这个问题的进一步思考:

这种加载 polyfill 的方式已经比传统方式先进了很多,但还是不完美,比如按照我们指定的浏览器范围需要引入的某个 polyfill,对于高版本浏览器来说可能还是多余。

个人觉得一种比较理想的方案是先在编译阶段通过静态分析确定可能需要 polyfill 的 API 范围但并不打包 polyfill 进去,而是当用户在浏览器中访问这个页面时,通过植入页面的JS脚本逐一检测当前浏览器是否支持这些新的 API,把不支持的找出来,通过一个请求去服务端加载对应的 polyfill 文件。当然这需要类似 polyfill.io 的服务端 polyfill 方案支持。未来我们会沿着这个方向继续探索。

持久化缓存

PWA 是真的火了,现在的项目里没用 PWA 出门都不好意思跟人打招呼。PWA 的一系列功能中最重磅的非离线缓存莫属了,虽说 H5 之前就有离线缓存(application cache)API,可惜不好用,PWA 离线缓存足以把它拍死在沙滩上。

从业务角度来讲,我们认为本项目不太适合离线访问,但我们可以利用 PWA 把静态资源进行离线缓存,提高页面访问速度。

在这种场景下,用 Service Worker 不缓存页面自身 HTML 和接口数据,只缓存静态资源,且优先使用缓存。非首次访问的情况下,静态资源都会走缓存,页面访问速度得以大幅提升。

但有一个问题,就是页面更新的问题。使用缓存优先策略,意味着每次进入页面时,在有缓存的情况下直接使用缓存。如果缓存有更新,在缓存更新之后需要刷新页面才能看到变化。自动刷新页面严重影响用户体验,而提示用户去手动刷新,在 APP 里看上去也有些奇怪,且不是所有有用户都会去手动刷新的。对于PLUS会员这种需求排队,更新频繁的项目,用户感受到的影响可能会更多。HTML5 的离线缓存 API 也有这个问题,这当然不是一个缺陷,而是“优先使用缓存”策略所决定的,只是不完全满足我们的需求罢了。

针对这个问题,我们的解决方案是当文件有更新时,同时修改缓存的版本号和页面中引用这个文件的 URL 中的版本号,让浏览器直接使用新文件,不使用缓存。在页面加载之后,缓存也会更新,下次访问时,还会走缓存。

这个方案还有优化空间,只有那些有变化的文件需要更改 URL 中的版本号,使用新文件,而页面中其他没有发生变化的静态资源还是可以也应该继续使用缓存。按照这个思路,我们应把代码中稳定的、不常变化的模块(比如 Vue 及其插件)尽量提取出来,让这部分内容尽可能使用缓存,当然必要的时候也可以通过相同的方式更新。而经常发生变化的部分(如业务代码)应独立打包,体积越小越好,以减小页面和缓存更新时的开销。

对于这些稳定公共模块的提取我们使用 webpack 内置的 DllPluginDllReferencePlugin 插件来实现,通过这两个插件提前对这些公共模块进行独立编译,打出一个 vendor.dll.js 的包,之后在这部分代码没有改动的情况下不再对它们进行编译,所以项目平时的构建速度也会提升不少。vendor.dll.js 包独立存在,hash 不会发生变化,特别适合持久化缓存。

于是,我们的业务代码有变化时,只需要以新版号发布业务包(app.js)即可,vendor.dll.js 依然使用本地缓存。

我们来看一下具体的加载情况。

首次访问,没有 PWA 缓存,所有资源都走线上。页面加载之后,PWA会缓存静态资源。

之后的访问,静态资源优先从缓存加载,速度极快。

当业务代码有更新时,更改页面中引用 app.js 文件的 URL 中的版本号,使得 app.js 不使用缓存,已缓存的其他静态资源依然可以使用缓存。同时更改缓存的版本号,缓存也会在页面加载之后更新,新的 app.js 文件也会被缓存。

再次访问时,包括 app.js 在内的静态资源依然全部走缓存。

请求优化

这个是一个前后端分离的项目,前端是标准的 Vue SPA,完全通过接口同后端进行数据交互。PLUS会员业务逻辑本身比较复杂,涉及很多种用户状态,页面逻辑也复杂。不同用户看到的界面不完全相同,这受用户状态和后台配置等多种因素影响。

部分接口存在相互依赖的关系,比如有接口要求传用户状态,因此需要先行通过用户信息接口拿到用户状态。再比如商品数据接口,需要先请求楼层配置信息接口,确定当前页面有哪些楼层,继而才能决定去请求哪些楼层的数据。

这种串行的接口请求拖慢了首屏的渲染,这是目前影响首页性能的一个主要问题,也是这次优化的一个重点。

服务端渲染(如Vue SSR),首屏直出当然是最理想的方案。但目前看来并不现实,这个项目的研发团队情况也比较复杂,前后端是两个跨职场、跨部门的团队,且需求巨多,页面改动频繁。完全的前后端分离更有助于明确职责,提高效率,减少扯皮。

另一个折中的方案是,在页面上直接引一个后端的模板文件,后端研发同事通过这个模板文件把用户状态、楼层配置等前置信息打到页面上,页面在浏览器中初始化的时候直接读取这些信息,然后再去请求那些依赖这些数据的接口。这样即可避免串行请求的问题,同时还减少了几个请求,有助于提高页面加载和渲染速度。这次优化,我们计划采用这种方案。

优化前:

优化后,关键请求大幅提前:

优化前:

优化后,页面开始渲染的时间明显提前:

梦想还是要有的。前后端分离是一种进步,但彻底的分离,也不尽善尽美,比如会有首屏加载速度和 SEO 方面的困扰。前后端分离+服务端首屏渲染 看起来是个更优的方案,它结合了前后端分离和服务端渲染两者的优点,既做到了前后端分离,又能保证首页渲染速度,还有利于 SEO。但在 Vue、React 等前端框架大行其道的今天,服务端渲染早已不是当年套 HTML 页面那么简单了,即便只渲染个首屏。前后端同构可能是比较好的解决方案,而这种场景下服务端渲染工作显然由前端来承担更合适,所以用 Node.js 搞个中间层是必要的。

骨架屏

通过一系列优化,除了客观上首屏渲染时间的明显缩短,我们还额外给页面加上了骨架屏(skeleton screen),让用户主观感受到的页面加载和渲染速度比真实情况还快。虚虚实实,用兵之道也,一切为了用户体验。

先来了解一下骨架屏的概念。骨架屏指的是在页面数据加载完成前,先给用户展示出的页面大致结构,之后渲染出真实页面内容将其换掉。这是近两年流行起来的加载控件,本质上是界面加载过程中的过渡效果。

在加载完成前把网页的大概轮廓预先显示,接着逐渐加载真正内容,这样既可缓解用户等待的焦灼情绪,又能使界面的加载过程显得更自然通畅,减少了长时间白屏或者闪烁。骨架屏能给人一种页面内容“已经渲染出一部分”的感觉,相较于传统的 loading 效果,体验更佳。

我们团队对骨架屏技术有比较深入的研究,开发过一个名为 @nutui/draw-page-structure [4]的webpack插件,可实现通过 puppeteer 自动生成纯 DOM 形式的页面骨架屏,并支持自动插入到指定页面。如果对自动生成的效果不满意,还允许定制和调整。

我们用这个插件在项目里小试了一把,效果还是不错滴。纯 DOM 形式的骨架屏代码,比图片、Canvas等形式数据量更小,调整起来也更灵活。

图片格式

Plus会员频道首页是一个典型的电商页面,包含大量的图片。使用新兴的图片格式可以大大减少加载的图片体积,并有助于提升图片的解析和渲染速度,进而提升页面渲染速度。对于移动web来说,还有一个重要的优点——节省用户的流量(中国移动30M5块钱呢,哈哈)。

去年我们在项目里应用了 WebP 格式,收效不错。比如某张背景图片,压缩后的 png 格式是35KB,而转成 WebP 只有4KB,两者基本看不出质量上的差别。

新兴图片格式的应用的主要障碍还是兼容性,以 WebP 为例,谷歌系的浏览器以及欧朋浏览器支持情况良好,Firefox、Edge 也都在新版本提供了支持,可惜苹果公司一直没有跟进,Safari 直到现在也没有要支持的迹象,iOS 上的应用如果想支持,还需自行打包解析库(经测试发现iOS版的京东APP已经提供了支持,点个赞)。

我们使用 WebP 的方式是在页面上通过JS判断当前浏览器是否支持 WebP,如果支持,则在 body 上增加一个名为 “webp” 的 class,同时把判断结果写入 localStorage,之后再进入页面时直接从 localStorage 里读取,不用每次都执行判断的代码了。然后在页面的 css 中通过 “.webp” 选择器、在 Vue 的图片过滤器中通过判断结果来决定是否加载 WebP 格式图片。

document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0

这次的优化,我们考虑增加对我厂 DPG 图片格式的支持。

DPG 是我厂基础架构部-智能存储部推出图片压缩技术,经过 DPG 压缩后的图片兼容 jpeg,同时全平台、全部浏览器都支持,DPG 是一种有损压缩技术,但通过5名用户10000张图片的人眼浏览测试,和 WebP 的清晰度对比没有差距。该技术可以有效地减少图片大小50%,减少 CDN 带宽流量 50%,加快图片用户在设备上的渲染速度。

基于我个人的理解, DPG 格式应该是对 jpeg 格式图片通过一定算法进行了二次压缩,其本质上还是 jpeg(虽然扩展名改了),这也才能有所谓”全平台浏览器支持“的可能性。所以,特别适合将 jpeg 格式的图片替换为 DPG 格式,当然前提是服务器上有 DPG 格式图片。我厂的图片系统会自动生成上传图片对应的 DPG 格式图片。所以我们定的 DPG 格式使用条件就是原图是 jpeg 格式,且图片位于我厂图片系统中。在兼顾既有的 WebP 格式图片加载逻辑的基础上,我们梳理后的图片加载逻辑如下图所示:

先聊到这里吧,我去参加PLUS会员项目的需求评审了……

扩展阅读

[1] https://www.npmjs.com/package/gaea-cli

[2] http://carefree.jd.com

[3] http://nutui.jd.com

[4] http://smock.jd.com

[5] https://www.npmjs.com/package/@nutui/draw-page-structure