单元测试
单元测试就是对一个函数、一个组件、一个类做的测试,它针对的粒度比较小。
它应该怎么写呢?
- 根据正确性写测试,即正确的输入应该有正常的结果。
- 根据异常写测试,即错误的输入应该是错误的结果。
对一个函数做测试
例如一个取绝对值的函数 abs()
,输入 1,2
,结果应该与输入相同;输入 -1,-2
,结果应该与输入相反。如果输入非数字,例如 "abc"
,应该抛出一个类型错误。
对一个类做测试
假设有这样一个类:
class Math { abs() { } sqrt() { } pow() { } ... }
单元测试,必须把这个类的所有方法都测一遍。
对一个组件做测试
组件测试比较难,因为很多组件都涉及了 DOM 操作。
例如一个上传图片组件,它有一个将图片转成 base64 码的方法,那要怎么测试呢?一般测试都是跑在 node 环境下的,而 node 环境没有 DOM 对象。
我们先来回顾一下上传图片的过程:
- 点击
<input type="file" />
,选择图片上传。 - 触发
input
的change
事件,获取file
对象。 - 用
FileReader
将图片转换成 base64 码。
这个过程和下面的代码是一样的:
document.querySelector('input').onchange = function fileChangeHandler(e) { const file = e.target.files[0] const reader = new FileReader() reader.onload = (res) => { const fileResult = res.target.result console.log(fileResult) // 输出 base64 码 } reader.readAsDataURL(file) }
上面的代码只是模拟,真实情况下应该是这样使用
document.querySelector('input').onchange = function fileChangeHandler(e) { const file = e.target.files[0] tobase64(file) } function tobase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = (res) => { const fileResult = res.target.result resolve(fileResult) // 输出 base64 码 } reader.readAsDataURL(file) }) }
可以看到,上面代码出现了 window 的事件对象 event
、FileReader
。也就是说,只要我们能够提供这两个对象,就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象:
// 重写 File window.File = function () {} // 重写 FileReader window.FileReader = function () { this.readAsDataURL = function () { this.onload && this.onload({ target: { result: fileData, }, }) } }
然后测试可以这样写:
// 提前写好文件内容 const fileData = 'data:image/test' // 提供一个假的 file 对象给 tobase64() 函数 function test() { const file = new File() const event = { target: { files: [file] } } file.type = 'image/png' file.name = 'test.png' file.size = 1024 it('file content', (done) => { tobase64(file).then(base64 => { expect(base64).toEqual(fileData) // 'data:image/test' done() }) }) } // 执行测试 test()
通过这种 hack 的方式,我们就实现了对涉及 DOM 操作的组件的测试。我的 vue-upload-imgs 库就是通过这种方式写的单元测试,有兴趣可以了解一下。
TDD 测试驱动开发
TDD 就是根据需求提前把测试代码写好,然后根据测试代码实现功能。
TDD 的初衷是好的,但如果你的需求经常变(你懂的),那就不是一件好事了。很有可能你天天都在改测试代码,业务代码反而没怎么动。
所以到现在为止,三年多的程序员生涯,我还没尝试过 TDD 开发。
虽然环境如此艰难,但有条件的情况下还是应该试一下 TDD 的。例如在你自己负责一个项目又不忙的时候,可以采用此方法编写测试用例。
测试框架推荐
我常用的测试框架是 jest,好处是有中文文档,API 清晰明了,一看就知道是干什么用的。
部署
在没有学会自动部署前,我是这样部署项目的:
- 执行测试
npm run test
。 - 构建项目
npm run build
。 - 将打包好的文件放到静态服务器。
一次两次还行,如果天天都这样,就会把很多时间浪费在重复的操作上。所以我们要学会自动部署,彻底解放双手。
自动部署(又叫持续部署 Continuous Deployment,英文缩写 CD)一般有两种触发方式:
- 轮询。
- 监听
webhook
事件。
轮询
轮询,就是构建软件每隔一段时间自动执行打包、部署操作。
这种方式不太好,很有可能软件刚部署完我就改代码了。为了看到新的页面效果,不得不等到下一次构建开始。
另外还有一个副作用,假如我一天都没更改代码,构建软件还是会不停的执行打包、部署操作,白白的浪费资源。
所以现在的构建软件基本采用监听 webhook
事件的方式来进行部署。
监听 webhook
事件
webhook 钩子函数,就是在你的构建软件上进行设置,监听某一个事件(一般是监听 push
事件),当事件触发时,自动执行定义好的脚本。
例如 Github Actions
,就有这个功能。
对于新人来说,仅看我这一段讲解是不可能学会自动部署的。为此我特地写了一篇自动化部署教程,不需要你提前学习自动化部署的知识,只要照着指引做,就能实现前端项目自动化部署。
前端项目自动化部署——超详细教程(Jenkins、Github Actions),教程已经奉上,各位大佬看完后要是觉得有用,不要忘了点赞,感激不尽。
监控
监控,又分性能监控和错误监控,它的作用是预警和追踪定位问题。
性能监控
性能监控一般利用 window.performance
来进行数据采集。
Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。
这个 API 的属性 timing
,包含了页面加载各个阶段的起始及结束时间。
为了方便大家理解 timing
各个属性的意义,我在知乎找到一位网友对于 timing
写的简介(忘了姓名,后来找不到了,见谅),在此转载一下。
timing: { // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和fetchStart相同。 navigationStart: 1543806782096, // 上一个页面unload事件抛出时的时间戳。如果没有上一个页面,这个值会返回0。 unloadEventStart: 1543806782523, // 和 unloadEventStart 相对应,unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。 unloadEventEnd: 1543806782523, // 第一个HTTP重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0。 redirectStart: 0, // 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的时间戳。 // 如果没有重定向,或者重定向中的一个不同源,这个值会返回0. redirectEnd: 0, // 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。 fetchStart: 1543806782096, // DNS 域名查询开始的UNIX时间戳。 //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和fetchStart一致。 domainLookupStart: 1543806782096, // DNS 域名查询完成的时间. //如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等 domainLookupEnd: 1543806782096, // HTTP(TCP) 域名查询结束的时间戳。 //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart一致。 connectStart: 1543806782099, // HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。 // 如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。 connectEnd: 1543806782227, // HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回0。 secureConnectionStart: 1543806782162, // 返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间戳。 requestStart: 1543806782241, // 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。 //如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。 responseStart: 1543806782516, // 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时 //(如果在此之前HTTP连接已经关闭,则返回关闭时)的时间戳。 responseEnd: 1543806782537, // 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时)的时间戳。 domLoading: 1543806782573, // 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的时间戳。 domInteractive: 1543806783203, // 当解析器发送DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。 domContentLoadedEventStart: 1543806783203, // 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。 domContentLoadedEventEnd: 1543806783216, // 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳 domComplete: 1543806783796, // load事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是0。 loadEventStart: 1543806783796, // 当load事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0. loadEventEnd: 1543806783802 }
通过以上数据,我们可以得到几个有用的时间
// 重定向耗时 redirect: timing.redirectEnd - timing.redirectStart, // DOM 渲染耗时 dom: timing.domComplete - timing.domLoading, // 页面加载耗时 load: timing.loadEventEnd - timing.navigationStart, // 页面卸载耗时 unload: timing.unloadEventEnd - timing.unloadEventStart, // 请求耗时 request: timing.responseEnd - timing.requestStart, // 获取性能信息时当前时间 time: new Date().getTime(),
还有一个比较重要的时间就是白屏时间,它指从输入网址,到页面开始显示内容的时间。
将以下脚本放在 </head>
前面就能获取白屏时间。
<script> whiteScreen = new Date() - performance.timing.navigationStart </script>
通过这几个时间,就可以得知页面首屏加载性能如何了。
另外,通过 window.performance.getEntriesByType('resource')
这个方法,我们还可以获取相关资源(js、css、img...)的加载时间,它会返回页面当前所加载的所有资源。
它一般包括以下几个类型
- sciprt
- link
- img
- css
- fetch
- other
- xmlhttprequest
我们只需用到以下几个信息
// 资源的名称 name: item.name, // 资源加载耗时 duration: item.duration.toFixed(2), // 资源大小 size: item.transferSize, // 资源所用协议 protocol: item.nextHopProtocol,
现在,写几行代码来收集这些数据。
// 收集性能信息 const getPerformance = () => { if (!window.performance) return const timing = window.performance.timing const performance = { // 重定向耗时 redirect: timing.redirectEnd - timing.redirectStart, // 白屏时间 whiteScreen: whiteScreen, // DOM 渲染耗时 dom: timing.domComplete - timing.domLoading, // 页面加载耗时 load: timing.loadEventEnd - timing.navigationStart, // 页面卸载耗时 unload: timing.unloadEventEnd - timing.unloadEventStart, // 请求耗时 request: timing.responseEnd - timing.requestStart, // 获取性能信息时当前时间 time: new Date().getTime(), } return performance } // 获取资源信息 const getResources = () => { if (!window.performance) return const data = window.performance.getEntriesByType('resource') const resource = { xmlhttprequest: [], css: [], other: [], script: [], img: [], link: [], fetch: [], // 获取资源信息时当前时间 time: new Date().getTime(), } data.forEach(item => { const arry = resource[item.initiatorType] arry && arry.push({ // 资源的名称 name: item.name, // 资源加载耗时 duration: item.duration.toFixed(2), // 资源大小 size: item.transferSize, // 资源所用协议 protocol: item.nextHopProtocol, }) }) return resource }
小结
通过对性能及资源信息的解读,我们可以判断出页面加载慢有以下几个原因:
- 资源过多
- 网速过慢
- DOM元素过多
除了用户网速过慢,我们没办法之外,其他两个原因都是有办法解决的,性能优化将在下一节《性能优化》中会讲到。