使用puppeteer对网页应用进行性能测试
提前注意
使用性能分析时, 不能使用
headless
模式, 否则录制效果会不准确
解决应用鉴权
由于我们的性能测试是针对用户操作文件体验的测量, 要访问用户的文件, 肯定绕不过权限的校验. 现在的网页应用也不再是单纯使用token
或者cookie
就能模拟真实用户的访问(其实我不太理解为什么要这样, 安全性问题?), 所以只能使用puppeteer
模拟真实用户的登录操作. 所幸所有竞品网站都还支持使用账户和密码登录.
这种模拟登录其实相当简单拉, 只要找到输入账号和密码的input
元素, 输入对应的信息, 然后提交就好拉. 不过有竞品为了实现一些酷炫的效果, input
元素不能直接查找, 以pixso
为例:
await page.goto('https://pixso.cn/user/login/')
const $btnLoginTypes = await page.$$(
'.sign-in-by-account--tabs .text-header5'
)
const $btnLoginByPassword = $btnLoginTypes[$btnLoginTypes.length - 1]
await $btnLoginByPassword.click()
const $signContainer = await page.waitForSelector(
'.sign-in-by-account--pw-box'
)
const $inputAccount = await $signContainer?.$('input.input--box')
const $inputPassword = await $signContainer?.$('input[type="password"]')
const $btnSign = await $signContainer?.$('.btn--next')
await $inputAccount?.type(this._account.name)
await $inputPassword?.type(this._account.password)
await $btnSign?.click()
await page.waitForNavigation()
值得注意上述代码有个page.waitForSelector
, 这里实际上就是等待真实的input
元素
首次展示文件内容的时间测量
我们需要评估用户通过访问文件链接, 直到其首次看到文件内容这整个过程的耗时. 所幸所有竞品都有动画loading的展示, 当loading动画不再展示, 文件内容就可以呈现给用户看了. 因此我们可以根据loading动画不再展示作为结束点进行测量. 同样的, page.waitForSelector
可以满足我们的需求. 以mastergo
为例:
await this.ready()
const page = await this.getMainPage()
const startTime = performance.now()
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.skeleton_screen_editpage')
await page.waitForSelector('.skeleton_screen_editpage', { hidden: true })
return {
costSecond: (performance.now() - startTime) / 1000,
}
通过domcontentloaded
事件来保证dom加载即可, page.goto
默认结束的时机是onload
, 这显然不符合我们的首屏展示需求. dom加载完成后, 让puppeteer
等待loading动画的dom元素, 最后以loading动画的消失为结束点(注意第二次waitForSelector
有个hidden
参数)
考虑到网络等不可抗力的外部因素, 单纯只测试一次首屏展示可能会存在误差, 所以我使用了中位数来决定首屏展示的时间
async function getMedianForTasks(
runTimes: number,
task: () => Promise<CanvasFirstPaintedResult>
) {
const times: number[] = []
if (runTimes % 2 === 0) {
runTimes += 1
}
for (let i = 0; i < runTimes; i++) {
const { costSecond } = await task()
times.push(costSecond)
}
return times[Math.ceil(times.length / 2)]
}
移动全选图形的性能测试
这个也很简单, 直接上代码了.
await this.waitForCanvasReady(url, { zoom: options.zoom })
const page = await this.getMainPage()
const keyboard = page.keyboard
const mouse = page.mouse
const pageSettings = this.options.pageSettings
await keyboard.down('ControlLeft')
await keyboard.press('A')
await keyboard.up('ControlLeft')
const x = pageSettings.width / 2
const y = pageSettings.height / 2
const testFn = () =>
mousemoveInRetanglePath(mouse, {
start: { x, y },
steps: options.mousemoveSteps,
delta: options.mousemoveDelta,
})
await this.recordPerformance(page, testFn, {
screenshots: true,
filename: 'xiaopiu-move-select-all.json',
})
通过keyboard
的down
, press
, up
可以模拟用户的Ctrl+A
的全选行为. 而后我在页面的中心点模拟拖曳的过程并录制. mousemoveInRetanglePath
实现如下:
export async function mousemoveInRetanglePath(
mouse: Mouse,
options: {
start: Point
steps: number
delta: number
}
) {
const { start: startPoint, steps, delta } = options
let x = startPoint.x
let y = startPoint.y
await mouse.move(x, y)
await mouse.down()
// to topLeft
for (let i = 0; i < steps; i++) {
x -= delta
y -= delta
await mouse.move(x, y)
}
// to topRight
for (let i = 0; i < steps; i++) {
x += delta
await mouse.move(x, y)
}
// to bottomRight
for (let i = 0; i < steps; i++) {
y += delta
await mouse.move(x, y)
}
// to bottomLeft
for (let i = 0; i < steps; i++) {
x -= delta
await mouse.move(x, y)
}
// to topLeft
for (let i = 0; i < steps; i++) {
y -= delta
await mouse.move(x, y)
}
// to origin
for (let i = 0; i < steps; i++) {
x += delta
y += delta
await mouse.move(x, y)
}
await mouse.up()
}
看完之后是不是很简单? 哈哈哈.
鼠标框选图形的性能测试
这个是通过puppeteer
的鼠标从画布的左上角逐步拖动到画布的右下角, 来模拟用户框选图形的过程:
await this.waitForCanvasReady(url, { zoom: options.zoom })
const canvasBoundingRect = await this.getCanvasBoundingRect('#canvas')
if (!canvasBoundingRect) {
console.error('canvas boundings not found!')
return
}
const page = await this.getMainPage()
const mouse = page.mouse
const rulerWidth = 0
const rulerHeight = 0
const startX = canvasBoundingRect.left + rulerWidth
const startY = canvasBoundingRect.top + rulerHeight
const endX = startX + canvasBoundingRect.width
const endY = startY + canvasBoundingRect.height
const testFn = () =>
mousemoveInDiagonalPath(mouse, {
start: { x: startX, y: startY },
end: { x: endX, y: endY },
delta: options.mousemoveDelta,
})
await this.recordPerformance(page, testFn, {
screenshots: true,
filename: 'mastergo-move-for-select-shapes.json',
})
既然上面提及了画布的***
, 那不可避免要获取画布
的相关信息, 这个画布其实就是个canvas
. 简单看下this.getCanvasBoundingRect
的实现:
async getCanvasBoundingRect(domSelector: string, page?: Page) {
if (typeof page === 'undefined') {
page = await this.getMainPage()
}
return await page.evaluate(function getBoundingRect(selector: string) {
const canvas = document.querySelector(selector)
const rect = canvas?.getBoundingClientRect()
let r: Omit<DOMRect, 'toJSON'> | undefined
if (rect) {
r = {
width: rect.width,
height: rect.height,
x: rect.x,
y: rect.y,
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.right,
}
}
return r
}, domSelector)
}
通过page.evalute
来获取画布的信息. 不过值得注意的是, puppeteer
的nodejs
进程和chromium
进程只能交换可序列化的数据. 因此如果直接传递DOMRect
类型的数据是不行的.
获取到画布信息之后就简单拉, 以下是mousemoveInDiagonalPath
的实现:
export async function mousemoveInDiagonalPath(
mouse: Mouse,
options: {
start: Point
end: Point
delta: number
}
) {
const { start: startPoint, delta, end: endPoint } = options
let x = startPoint.x
let y = startPoint.y
const dx = endPoint.x - x
const dy = endPoint.y - y
const xSteps = dx / delta
const ySteps = dy / delta
const steps = Math.min(xSteps, ySteps)
const deltaX = dx / steps
const deltaY = dy / steps
await mouse.move(x, y)
await mouse.down()
for (let i = 0; i < steps; i++) {
x += deltaX
y += deltaY
await mouse.move(x, y)
}
await mouse.up()
}
画布中心点缩放的性能测试
这个本来我以为是最简单的, 不就鼠标移动到画布中心点, 然后控制滚轮吗. 然而事实是, 如果wheel
事件是注册在canvas
本身, 则模拟滚动无效(即使我再让canvas
聚焦). 这个原因未明, 有待考究
因此我使用了一个比较搓的方法实现了这个测试: 让页面发送wheel
事件给注册wheel
的宿主, 通过这种方式来达到缩放的效果. 代码如下:
async zoomCanvasByMockWheel(registedWheelSelector: string = '') {
const page = await this.getMainPage()
const mouse = page.mouse
const pageSettings = this.options.pageSettings
await mouse.move(pageSettings.width / 2, pageSettings.height / 2)
return page.evaluate(
({
clientX,
clientY,
ctrlKey = true,
selector,
}: Partial<WheelEventInit> & { selector: string }) => {
let node = selector
? (document.querySelector(selector) as Element)
: document
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const makeMockWheel = (zoomType: 'large' | 'small') => {
return () => {
const seed = zoomType === 'large' ? -1 : 1
const initOptions: WheelEventInit = {
deltaY: seed * 125,
ctrlKey,
clientX,
clientY,
}
const mouseWheelEvent = new WheelEvent('mousewheel', initOptions)
const wheelEvent = new WheelEvent('wheel', initOptions)
node.dispatchEvent(mouseWheelEvent)
node.dispatchEvent(wheelEvent)
return sleep(16.67)
}
}
const queue: Array<() => Promise<unknown>> = []
const loop = (): Promise<void> => {
const fn = queue.shift()
if (fn) {
return fn().then(loop)
}
return Promise.resolve()
}
for (let i = 0, l = 20; i < l; i++) {
queue.push(makeMockWheel('large'))
}
for (let i = 0, l = 20; i < l; i++) {
queue.push(makeMockWheel('small'))
}
return loop()
},
{
selector: registedWheelSelector,
clientX: pageSettings.width / 2,
clientY: pageSettings.height / 2,
}
)
}
收集性能数据
说了这么多, 到底如何收集性能数据呢? 正如前面所说, 使用page.tracing
相关的api即可:
async recordPerformance(
page: Page,
fn: () => Promise<void>,
options: TracingOptions & { filename: string }
) {
await page.tracing.start({
screenshots: options.screenshots,
path: options.path || `${fileSaver.performancesDir}/${options.filename}`,
})
await fn()
await page.tracing.stop()
}
代码运行结束后, 就会在指定路径生成*.json
的文件. 你可以通过devtools
-> performance
-> load file
来加载保存的json
文件, 这样就可以分析程序性能情况拉.
Puppeteer大法好
以前用puppeteer
主要是用来完成一些自动化相关的功能, 万万没想到还能做性能测试(在下才疏学浅, 认知不足). 对chromium
掌控力越强, 能做的事情越多, puppeteer
大法好!