读了京东的plus会员前端性能优化文章,有以下几点感悟:
1、学习webpack,babel等
引用部分文章:
一、架构升级 项目计划迁移到
Gaea4.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的执行速度
- 生产环境构建体积更小
- 开发环境通过优化的增量构建机制提升构建速度,同时提供详细的错误和提示
其次,
Gaea4.0
的 Babel 是 7.0 版的,基于 Babel7 可以实现更智能的 Babel polyfill 按需加载。 再次,本次优化计划尝试的PWA、骨架屏等方案,Gaea4.0
都可以给予基础支持。 最后,Gaea4.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。目前项目中采用这种方案,这次考虑予以优化,减少加载的代码体积。 如上文提到,这一波改造会把项目迁移到
Gaea4.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
,它有两个值需要重点关注:entry
和usage
。 当值为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-env
的useBuiltIns
选项值设为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 方案支持。未来我们会沿着这个方向继续探索。
2、持久化缓存
三、持久化缓存
PWA
是真的火了,现在的项目里没用PWA
出门都不好意思跟人打招呼。PWA
的一系列功能中最重磅的非离线缓存莫属了,虽说 H5 之前就有离线缓存(application cache)API,可惜不好用,PWA
离线缓存足以把它拍死在沙滩上。 从业务角度来讲,我们认为本项目不太适合离线访问,但我们可以利用PWA
把静态资源进行离线缓存,提高页面访问速度。 在这种场景下,用ServiceWorker
不缓存页面自身 HTML 和接口数据,只缓存静态资源,且优先使用缓存。非首次访问的情况下,静态资源都会走缓存,页面访问速度得以大幅提升。 但有一个问题,就是页面更新的问题。使用缓存优先策略,意味着每次进入页面时,在有缓存的情况下直接使用缓存。如果缓存有更新,在缓存更新之后需要刷新页面才能看到变化。自动刷新页面严重影响用户体验,而提示用户去手动刷新,在 APP 里看上去也有些奇怪,且不是所有有用户都会去手动刷新的。对于PLUS会员这种需求排队,更新频繁的项目,用户感受到的影响可能会更多。HTML5 的离线缓存 API 也有这个问题,这当然不是一个缺陷,而是“优先使用缓存”策略所决定的,只是不完全满足我们的需求罢了。 针对这个问题,我们的解决方案是当文件有更新时,同时修改缓存的版本号和页面中引用这个文件的 URL 中的版本号,让浏览器直接使用新文件,不使用缓存。在页面加载之后,缓存也会更新,下次访问时,还会走缓存。 这个方案还有优化空间,只有那些有变化的文件需要更改 URL 中的版本号,使用新文件,而页面中其他没有发生变化的静态资源还是可以也应该继续使用缓存。按照这个思路,我们应把代码中稳定的、不常变化的模块(比如 Vue 及其插件)尽量提取出来,让这部分内容尽可能使用缓存,当然必要的时候也可以通过相同的方式更新。而经常发生变化的部分(如业务代码)应独立打包,体积越小越好,以减小页面和缓存更新时的开销。 对于这些稳定公共模块的提取我们使用 webpack 内置的DllPlugin
和DllReferencePlugin
插件来实现,通过这两个插件提前对这些公共模块进行独立编译,打出一个 vendor.dll.js 的包,之后在这部分代码没有改动的情况下不再对它们进行编译,所以项目平时的构建速度也会提升不少。vendor.dll.js 包独立存在,hash 不会发生变化,特别适合持久化缓存。 于是,我们的业务代码有变化时,只需要以新版号发布业务包(app.js)即可,vendor.dll.js 依然使用本地缓存。 我们来看一下具体的加载情况。 首次访问,没有PWA
缓存,所有资源都走线上。页面加载之后,PWA会缓存静态资源。之后的访问,静态资源优先从缓存加载,速度极快。
当业务代码有更新时,更改页面中引用 app.js 文件的 URL 中的版本号,使得 app.js 不使用缓存,已缓存的其他静态资源依然可以使用缓存。同时更改缓存的版本号,缓存也会在页面加载之后更新,新的 app.js 文件也会被缓存。
再次访问时,包括 app.js 在内的静态资源依然全部走缓存。
3、请求优化
这个是一个前后端分离的项目,前端是标准的 Vue SPA,完全通过接口同后端进行数据交互。PLUS会员业务逻辑本身比较复杂,涉及很多种用户状态,页面逻辑也复杂。不同用户看到的界面不完全相同,这受用户状态和后台配置等多种因素影响。 部分接口存在相互依赖的关系,比如有接口要求传用户状态,因此需要先行通过用户信息接口拿到用户状态。再比如商品数据接口,需要先请求楼层配置信息接口,确定当前页面有哪些楼层,继而才能决定去请求哪些楼层的数据。 这种串行的接口请求拖慢了首屏的渲染,这是目前影响首页性能的一个主要问题,也是这次优化的一个重点。 服务端渲染(如Vue SSR),首屏直出当然是最理想的方案。但目前看来并不现实,这个项目的研发团队情况也比较复杂,前后端是两个跨职场、跨部门的团队,且需求巨多,页面改动频繁。完全的前后端分离更有助于明确职责,提高效率,减少扯皮。 另一个折中的方案是,在页面上直接引一个后端的模板文件,后端研发同事通过这个模板文件把用户状态、楼层配置等前置信息打到页面上,页面在浏览器中初始化的时候直接读取这些信息,然后再去请求那些依赖这些数据的接口。这样即可避免串行请求的问题,同时还减少了几个请求,有助于提高页面加载和渲染速度。这次优化,我们计划采用这种方案。 优化前:
![]()
![]()
梦想还是要有的。前后端分离是一种进步,但彻底的分离,也不尽善尽美,比如会有首屏加载速度和 SEO 方面的困扰。
前后端分离+服务端首屏渲染
看起来是个更优的方案,它结合了前后端分离和服务端渲染两者的优点,既做到了前后端分离,又能保证首页渲染速度,还有利于 SEO。但在 Vue、React 等前端框架大行其道的今天,服务端渲染早已不是当年套 HTML 页面那么简单了,即便只渲染个首屏。前后端同构可能是比较好的解决方案,而这种场景下服务端渲染工作显然由前端来承担更合适,所以用 Node.js 搞个中间层是必要的。
4、骨架屏
通过一系列优化,除了客观上首屏渲染时间的明显缩短,我们还额外给页面加上了骨架屏(skeleton screen),让用户主观感受到的页面加载和渲染速度比真实情况还快。虚虚实实,用兵之道也,一切为了用户体验。 先来了解一下骨架屏的概念。骨架屏指的是在页面数据加载完成前,先给用户展示出的页面大致结构,之后渲染出真实页面内容将其换掉。这是近两年流行起来的加载控件,本质上是界面加载过程中的过渡效果。 在加载完成前把网页的大概轮廓预先显示,接着逐渐加载真正内容,这样既可缓解用户等待的焦灼情绪,又能使界面的加载过程显得更自然通畅,减少了长时间白屏或者闪烁。骨架屏能给人一种页面内容“已经渲染出一部分”的感觉,相较于传统的 loading 效果,体验更佳。 我们团队对骨架屏技术有比较深入的研究,开发过一个名为
@nutui/draw-page-structure
[4]的webpack插件,可实现通过 puppeteer 自动生成纯 DOM 形式的页面骨架屏,并支持自动插入到指定页面。如果对自动生成的效果不满意,还允许定制和调整。 我们用这个插件在项目里小试了一把,效果还是不错滴。纯 DOM 形式的骨架屏代码,比图片、Canvas等形式数据量更小,调整起来也更灵活。
5、图片格式
6、扩展阅读
[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