腾讯防水墙为您提供专业的业务安全资讯与服务,用领先的技术解决欺诈、薅羊毛、刷单、爬虫、撞库、人机对抗等问题,守护您的业务安全

验证码前端性能分析及优化实践

发布日期:2018-08-16 10:12:01 +0000


点击上方蓝字关注腾讯防水墙

了解第一手企业安全资讯


/  /  /  /  /  /  /  /  /  /  /


在越来越注重用户体验的趋势下,验证码作为一种自打诞生以来就被贴上“多余”标签的产品,更应该给用户提供良好的体验。本文以滑动验证码作为切入点,分析了验证码可能存在的性能问题,并通过资源合并、DOM操作优化和选择性内联打包等方式有效减少页面加载时间。同时本文总结了移动端组件化适配容易遇到的问题,并提供了规范化的解决方案。希望本文能给前端做性能优化的同学提供一些不一样的实践思路。



1. 验证码现状


优化前,我们优先对现有验证码进行了分析,总结其存在的主要问题。

  • 资源乱象

  • 无节制的DOM及异步等待

  • 移动端适配不规范


资源乱象


资源乱象是老旧工程普遍存在的问题,作为jQuery时代遗留下来的验证码工程,该现象同样存在。表1.1展现了旧版验证码资源文件情况,从表中可以看出,旧版验证码资源使用相对混乱,尤其是js/css资源,分散的多个小文件大大增加了HTTP请求数量。验证码的图片资源都是一些小图标,虽然前期的一些优化已经将这些小图标合并成雪碧图,但对于少量的小图标仍然有优化空间。


表1.1 验证码前端资源文件分布


无节制的DOM及异步等待


验证码拥有诸多定制化能力,包括弹出形式、多语言、主题色、头部和反馈按钮等,旧版验证码为了实现定制化能力进行了大量冗余的DOM操作,不仅消耗性能,而且加载过程会出现界面闪烁。同时旧版验证码为了解决一些异步等待的问题,引入了诸多定时器,看似无脑,却大大增加了逻辑复杂度,使得代码难以维护。


移动端适配不规范


旧版验证码未进行体系化的移动端适配,所有适配的样式均为js实时计算完成后写入DOM的style中,逻辑复杂。此前收到诸多用户反馈均与此适配方式有关,前端经常需要为了适配某一种机型而堆叠代码,苦不堪言。


面临着如此多的历史包袱,重构势在必行。



2. 方案及技术选型


图2.1是验证码的前端框架,验证码的主要页面使用iframe加载,iframe隔离了业务页面,为验证码提供了更可控更干净的执行环境。业务页面接入时需要引入一个验证码的入口js,该js负责分配不同类型的验证码、创建iframe,并管理iframe与业务页面的通信。


图2.1 验证码前端框架


任何完全推翻原有架构的重构都是耍流氓。验证码重构的原则是保持原有的业务逻辑,只重构前端实现。因此本次重构将围绕绿色iframe部分进行,其他部分保持不变,这样既能保证业务平滑过渡,也能让原有测试样例完整覆盖。


一个项目能否具备良好的可维护性,前期技术选型尤为重要。重构前,基于对验证码整体功能的评估,最终将整体优化方案划分为3个部分。本文也将围绕这3个部分,讲述验证码前端性能优化的思考及方案。


  1. 资源合并

    • 模块化

    • DOM操作优化

    • 资源合并打包

    • 图片、样式内联


  2. 规范移动端适配

    • 引入flexible.js

    • rem自动换算

    • iframe内缩放问题

    • webview内适配问题


  3. 提升用户预期



3. 资源合并


资源合并是一个很大的方向,不同的产品合并方式各不相同。如图3.1所示,是整个验证码的加载流程,绿框内的节点为本次优化需要关注的阶段。


图3.1 验证码加载流程


重构前我们对各阶段耗时进行了分析,其中DOM渲染和验证码主逻辑耗时占比相对较高,属于需要重点优化的部分。而验证码类型请求、iframe创建、iframe请求和图片加载与浏览器性能及服务端响应相关,因此暂时不进行优化,后期考虑预加载。

图3.2 验证码加载时间


3.1 模块化


从加载流程可以看出,验证码并不是一个简单的几十上百行代码就能实现的工程,复杂的逻辑需要分模块才能更好地管理团队分工和整体进度,模块化也有利于单元测试,保证工程的质量。然而,JavaScript并不是一种模块化编程语言,其模块化需要依赖额外的工具实现。早期的模块化有多种不同的写法,想要详细了解的读者可阅读阮一峰老师的《Javascript模块化编程(一):模块的写法》。


模块化发展的中期,require.js和sea.js占据了模块化解决方案的半壁江山,这两个方案都是对模块化很好的实践。验证码将采用Node.js模块系统的CommonJS规范,借助webpack4将模块打包到一起。最终验证码工程中模块引用及导出相关的代码非常简单:

const module1 = require('./module1');

// ...
function Captcha({}
// ...

module.exports = Captcha;


模块化是资源合并的基础,这里的模块化不仅是将业务逻辑分模块加载,也包含了CSS、Icon等样式和图标。只有把所有资源均纳入到模块化管理中,配合webpack不同的loader,才能更细粒度地控制合并资源。


3.2 DOM操作优化


DOM操作是整个JavaScript执行周期中最耗时的操作之一,不合理的DOM操作不仅会触发页面大量的重排和重绘,还有可能导致页面假死。良好的DOM组织结构应该遵循不需要大量重排(Reflow),DOM修改尽量用重绘(Repaint)代替的原则。


图3.3 重绘&重排


重绘是指一个元素的外观被改变,但布局没有发生变化,包括改变visibility、outline、background等属性;而重排是DOM的变化影响了元素的几何属性,浏览器会重新计算元素的几何属性,使渲染树中受到影响的部分失效,会验证DOM树上的所有其它节点的visibility属性,十分低效,这类操作包括改变窗口大小、文字大小、内容,style属性等。


本次重构针对旧版工程大量不合理的DOM操作进行优化,采取的解决方案包括:


  • 最小化重绘和重排

    重新合理组织了DOM结构,将DOM的多样化用CSS类的方式表示,尽量控制DOM的显示或隐藏,避免添加或删除,对于一些要修改的DOM结构,采用修改className的方式,而不是多次读写style,避免使用js修复布局,减小DOM操作对页面渲染造成的影响。


  • 缓存DOM元素

    如果某个节点将在后续进行多次操作,则将其利用变量存储起来,而不是每次进行操作时都去查找一遍该节点。


  • 消灭定时器

    对于一些动画效果的实现,避免使用setTimeout或setInterval,而是用requestAnimationFrame代替


3.3 资源合并打包


js脚本的加载和执行会阻塞HTML Parser,本次优化本着"all in one"的原则,运用组件化思想,将复杂的逻辑抽象成多个独立的模块,把前端代码拆分成多个小文件,利用webpack灵活地处理模块依赖、提取公共模块最终合成业务所需代码。通过webpack-bundle-analyzer插件将打包后的内容束展示为方便交互的直观树状图,如图3.4所示:


图3.4 验证码项目结构


滑动验证码支持20多个国家的23种语言,文案数多达30余条,因此语言包的体积占了整个工程的很大比例。Zepto有很多模块,通常为了使引用的zepto库尽可能小,会根据项目的实际需要打包所需的模块进去,验证码项目只需用到事件处理、网络请求和一些简单的动画效果,因此我们对zepto、event、ajax和fx(动画模块)四个模块进行了打包处理,生成本项目特有的zepto库。整个项目结构中占用体积较大的js来自多语言包和zepto库,其余都是体积可忽略不计的小组件。


通过webpack对js的模块化管理,整个项目的模块结构变得十分清晰,方便维护和重构。Js资源文件的数量由10余个减小为1个,体积也得到了很好的控制,减少了HTTP请求数量并且没有增加带宽消耗。


3.4 图片、样式内联


验证码中的图片均为Icon,特点是体积小但数量较多,合并成雪碧图后还是需要占用几十KB的空间,并且加载雪碧图需要一次额外的HTTP请求。针对验证码小图标众多这一特性,我们将这些小图标base64编码到css文件中,减少HTTP请求数量,并且并不会占用很大的空间。

test: /\.(png|jpg|gif)$/,
use: [{
    loader: 'url-loader',
    options: {
        limit: 8192
    }
}]


在工程中添加上述webpack配置,采用url-loader插件自动对小于8KB的小图标进行DataURL处理,直接把图片资源内嵌到页面中提供给浏览器解析,这样就可以不用发送HTTP请求,提升资源文件加载速度的同时也节省了带宽资源。


验证码的DOM结构并不算太复杂,并且经过对SCSS的模块化处理,使用PostCSS生态和cssnext来支持自定义变量、样式内嵌等特性,最终编译生成的CSS文件已经很简洁,体积只有十几KB,因此我们考虑不再将其作为一个单独的资源文件引用,而是直接将其inline到html中,在html的head标签中添加如下style配置:

<style type="text/css"><%= compilation.assets[htmlWebpackPlugin.files.css[0].substr(htmlWebpackPlugin.files.publicPath.length)].source() %></style>


webpack的compilation对象继承于compiler,能获取一切编译后的内容,因此可以直接把打包生成的css样式插入html,减少了额外的HTTP请求,并且能避免偶发的CDN资源加载失败导致的页面显示异常。



4. 移动端适配


4.1 引入flexible.js


flexible.js是一个开源的用于终端设备的适配解决方案,主要用于解决各种不同尺寸移动设备的大小自适应问题,其原理是通过移动设备的dpr(设备像素比=物理像素/设备独立像素)和屏幕宽度来动态改变html的font-size大小。因此我们选择flexible.js用于验证码的页面适配,在页面引入flexible.js后,首先获取设备型号,然后根据不同设备在标签上添加一个data-dpr和font-size样式,并结合我们的项目对其进行改进,获得更加完美的兼容效果。


4.2 rem自动换算


下面是验证码页面的缩放配置,其中,baseDpr表示基准的dpr值为1,rem单位以375宽度的屏幕为基准,即1rem==37.5px,并提供'!px'和'!no'两种特殊的单位转换方式。

// px2rem
const px2remConfigs = {
    baseDpr: 1,
    remUnit: 37.5,
    forcePxComment: '!px',
    keepComment: '!no'
};


通过postcss-loader的px2rem插件将原生scss文件中所有用px单位表示的大小自动编译转换成能够自适应于各屏幕大小的rem值。同时,会对px后面带'!px'和'!no'后缀的情况做特殊处理,在px后面添加'!px'表示根据dpr设置成不同的rem值,一般字体用该单位修饰;在px后面添加'!no'表示不转化px直接原样输出,一般boder边框用该单位修饰。

loader: "postcss-loader",
options: {
    plugins: [
        autoprefixer, // 自动添加前缀
        px2rem(px2remConfigs)
    ]
}


针对一些元素尺寸、位置等需要动态变化的情况,比如横竖屏切换时iframe整体大小需要自适应,再比如每刷新一次,小拼图的宽、高、top值都要重新计算,此时只需要注册相应的回调函数,在回调函数内进行相应的逻辑处理即可。flexible检测到resize、pageshow或其它调用refreshRem方法的时候,会回调在验证码内注册的回调数组(resizeCb)中的所有方法。

listen: function(order, cb) {
    if(flexible.resizeCb) {
        flexible.resizeCb.splice(order, 0, cb);
    }
}
flexible.listen(0function() {...};
flexible.listen(1function() {...};
flexible.listen(2function() {...};
...
if(flexible.resizeCb) {
    for(var i=0; i < flexible.resizeCb.length; i++) {
        try{
            flexible.resizeCb[i] && flexible.resizeCb[i]();
        } catch(e) {}
    }
}


4.3 iframe内缩放问题


验证码作为一个web组件提供给业务使用,在iframe内部默认不设置视口(viewport),在dpr大于1的时候整个iframe会被压缩成1/dpr,如图4.1左侧所示。因此需要根据业务页面设置的viewport来设置缩放比例,通过获取父页面的scale值并将其透传到iframe内部的方式来设置正确的缩放比例,最终得到如图4.1右侧所示的效果。


图4.1 iframe内缩放问题


4.4 webview内适配问题


虽然flexible能比较完美地适配移动端页面,然而在一些特殊的安卓机器中仍然会存在很诡异的适配问题,如图4.2所示:


图4.2 webview内适配问题


产生这种情况的原因是安卓部分webview修改了默认字体,使得最终显示的1rem的px值和设置的值不一致,导致页面显示异常。正常情况下,rem是根据html的最终font-size进行响应的,并且对于大部分机型,设置的值和最终的响应值是相等的,即:

1rem == finalDocElementFontSize == docElementFontSize


然而在小米MAX和荣耀8等机型中,最终的响应值要大于设置的值,导致以rem为单位的DOM元素都显示过大,就会出现图4.2中小拼图缺口大小不匹配、图片超出屏幕区域的异常情况。


表4.1 优化前后采集数据对比


如表4.1所示,其中,优化前所在行是我们采集上来的异常数据,可以明显看到,真实的1rem对应的px值要远大于设置的值,此时设定一个阈值,当设置值和最终响应值差值大于0.5时,重新计算fontsize大小:

try {
    var finalDocElementFontSize = parseFloat(getComputedStyle(docEl).fontSize);
    if(Math.abs(finalDocElementFontSize - docElementFontSize) > 0.5) {
        var delta = finalDocElementFontSize / docElementFontSize;
        docEl.style.fontSize = (docElementFontSize / delta) + 'px';
    }
catch(e) {}


得到矫正后的优化后数据,使得最终的响应值接近我们最初想要设置的值,页面就能达到如图4.3所示完美的适配效果:


图4.3 优化后适配效果


5. 提升用户预期


图5.1是旧版验证码的加载流程,白屏时间接近2s,并且整个过程衔接得并不自然。


图5.1 旧验证码加载流程


因此我们在重构中引入了Skeleton Screen(加载占位图),在验证码加载预期填充灰色的占位图,实现界面加载过程中的平滑过渡效果。这个概念来源于iOS设计规范中的Lanuch Screen(启动屏幕),主要目的是为了解决等待加载过程中出现白屏或界面闪烁造成的割裂感。


图5.2 骨架屏


实现的效果如图5.2所示,用占位图展示验证码的大致骨架,大大提升用户对验证码加载的预期。最终加载流程如图5.3所示。


图5.3 新验证码加载流程


加载占位图的显示不依赖任何页面外部资源,在验证码的HTML加载完成之前就可以显示出验证码的大致轮廓,增加用户的等待预期并减少长时间白屏带来的焦躁情绪,用户体验得以提升。



6. 优化效果及总结


经过本轮重构以后,验证码整体性能得到了大幅提升。其中,验证码体积减少了38%,http资源请求数由10余个减少为2个。引入骨架屏有效缩短了白屏时间,最耗时的DOM渲染和验证码主逻辑执行时间也分别减少了50%和60%。安卓下全流程加载平均耗时从3.9s减少为1.9s,降低了51%;ios下从3s减少为1.7s,降低了43%。


本次移动端验证码重构是对前端性能优化的一次完整实践,模块化、资源合并打包、按需缓存、代码压缩等前端的优化思路,在基础前端产品中应用后效果尤为显著,优化后的验证码很大程度上减少了网络链路的开销。并且重构过程中梳理了验证码的加载流程,删减了流程中不合理的异步等待,最终的主逻辑代码中已经没有定时器的影子,这再一次证明了前端通用优化思路的可行性。


一个项目存在历史包袱在所难免,随着各种新技术层出不穷,一些老旧的思想和方式势必被更好的实践替代,性能优化是值得前端从事者深入研究的领域,只有不断实践通用优化方法和探索最佳实践标准,才能提升用户体验从而为业务创造价值。为了用户体验,我们一直在努力。


我们将对各类技术进行持续的 探索与 研究,为业务提供优质的安全解决方案以及威胁情报感知服务。如果对相关技术及业务安全感兴趣, 欢迎访问007.qq.com,或者关注 微信公众号 了解更多。