index.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. // @ts-check
  2. import path from 'node:path'
  3. import { parseArgs } from 'node:util'
  4. import { mkdir, rm, writeFile } from 'node:fs/promises'
  5. import Vue from '@vitejs/plugin-vue'
  6. import { build } from 'vite'
  7. import connect from 'connect'
  8. import sirv from 'sirv'
  9. import { launch } from 'puppeteer'
  10. import colors from 'picocolors'
  11. import { exec, getSha } from '../scripts/utils.js'
  12. import process from 'node:process'
  13. import readline from 'node:readline'
  14. // Thanks to https://github.com/krausest/js-framework-benchmark (Apache-2.0 license)
  15. const {
  16. values: {
  17. skipLib,
  18. skipApp,
  19. skipBench,
  20. vdom,
  21. noVapor,
  22. port: portStr,
  23. count: countStr,
  24. warmupCount: warmupCountStr,
  25. noHeadless,
  26. devBuild,
  27. },
  28. } = parseArgs({
  29. allowNegative: true,
  30. allowPositionals: true,
  31. options: {
  32. skipLib: {
  33. type: 'boolean',
  34. short: 'l',
  35. },
  36. skipApp: {
  37. type: 'boolean',
  38. short: 'a',
  39. },
  40. skipBench: {
  41. type: 'boolean',
  42. short: 'b',
  43. },
  44. noVapor: {
  45. type: 'boolean',
  46. },
  47. vdom: {
  48. type: 'boolean',
  49. short: 'v',
  50. },
  51. port: {
  52. type: 'string',
  53. short: 'p',
  54. default: '8193',
  55. },
  56. count: {
  57. type: 'string',
  58. short: 'c',
  59. default: '30',
  60. },
  61. warmupCount: {
  62. type: 'string',
  63. short: 'w',
  64. default: '5',
  65. },
  66. noHeadless: {
  67. type: 'boolean',
  68. },
  69. devBuild: {
  70. type: 'boolean',
  71. short: 'd',
  72. },
  73. },
  74. })
  75. const port = +(/** @type {string}*/ (portStr))
  76. const count = +(/** @type {string}*/ (countStr))
  77. const warmupCount = +(/** @type {string}*/ (warmupCountStr))
  78. const sha = await getSha(true)
  79. if (!skipLib) {
  80. await buildLib()
  81. }
  82. if (!skipApp) {
  83. await rm('client/dist', { recursive: true }).catch(() => {})
  84. vdom && (await buildApp(false))
  85. !noVapor && (await buildApp(true))
  86. }
  87. const server = startServer()
  88. if (!skipBench) {
  89. await benchmark()
  90. server.close()
  91. }
  92. async function buildLib() {
  93. console.info(colors.blue('Building lib...'))
  94. /** @type {import('node:child_process').SpawnOptions} */
  95. const options = {
  96. cwd: path.resolve(import.meta.dirname, '..'),
  97. stdio: 'inherit',
  98. env: { ...process.env, BENCHMARK: 'true' },
  99. }
  100. const buildOptions = devBuild ? '-df' : '-pf'
  101. const [{ ok }, { ok: ok2 }, { ok: ok3 }, { ok: ok4 }] = await Promise.all([
  102. exec(
  103. 'pnpm',
  104. `run --silent build shared compiler-core compiler-dom compiler-vapor ${buildOptions} cjs`.split(
  105. ' ',
  106. ),
  107. options,
  108. ),
  109. exec(
  110. 'pnpm',
  111. 'run --silent build compiler-sfc compiler-ssr -f cjs'.split(' '),
  112. options,
  113. ),
  114. exec(
  115. 'pnpm',
  116. `run --silent build vue-vapor ${buildOptions} esm-browser`.split(' '),
  117. options,
  118. ),
  119. exec(
  120. 'pnpm',
  121. `run --silent build vue ${buildOptions} esm-browser-runtime`.split(' '),
  122. options,
  123. ),
  124. ])
  125. if (!ok || !ok2 || !ok3 || !ok4) {
  126. console.error('Failed to build')
  127. process.exit(1)
  128. }
  129. }
  130. /** @param {boolean} isVapor */
  131. async function buildApp(isVapor) {
  132. console.info(
  133. colors.blue(`\nBuilding ${isVapor ? 'Vapor' : 'Virtual DOM'} app...\n`),
  134. )
  135. if (!devBuild) process.env.NODE_ENV = 'production'
  136. const CompilerSFC = await import(
  137. '../packages/compiler-sfc/dist/compiler-sfc.cjs.js'
  138. )
  139. const prodSuffix = devBuild ? '.js' : '.prod.js'
  140. /** @type {any} */
  141. const TemplateCompiler = await import(
  142. (isVapor
  143. ? '../packages/compiler-vapor/dist/compiler-vapor.cjs'
  144. : '../packages/compiler-dom/dist/compiler-dom.cjs') + prodSuffix
  145. )
  146. const runtimePath = path.resolve(
  147. import.meta.dirname,
  148. (isVapor
  149. ? '../packages/vue-vapor/dist/vue-vapor.esm-browser'
  150. : '../packages/vue/dist/vue.runtime.esm-browser') + prodSuffix,
  151. )
  152. const mode = isVapor ? 'vapor' : 'vdom'
  153. await build({
  154. root: './client',
  155. base: `/${mode}`,
  156. define: {
  157. 'import.meta.env.IS_VAPOR': String(isVapor),
  158. },
  159. build: {
  160. minify: !devBuild && 'terser',
  161. outDir: path.resolve('./client/dist', mode),
  162. rollupOptions: {
  163. onwarn(log, handler) {
  164. if (log.code === 'INVALID_ANNOTATION') return
  165. handler(log)
  166. },
  167. },
  168. },
  169. resolve: {
  170. alias: {
  171. 'vue/vapor': runtimePath,
  172. vue: runtimePath,
  173. },
  174. },
  175. clearScreen: false,
  176. plugins: [
  177. Vue({
  178. compiler: CompilerSFC,
  179. template: { compiler: TemplateCompiler },
  180. }),
  181. ],
  182. })
  183. }
  184. function startServer() {
  185. const server = connect().use(sirv('./client/dist')).listen(port)
  186. printPort(port)
  187. process.on('SIGTERM', () => server.close())
  188. return server
  189. }
  190. async function benchmark() {
  191. console.info(colors.blue(`\nStarting benchmark...`))
  192. const browser = await initBrowser()
  193. await mkdir('results', { recursive: true }).catch(() => {})
  194. if (!noVapor) {
  195. await doBench(browser, true)
  196. }
  197. if (vdom) {
  198. await doBench(browser, false)
  199. }
  200. await browser.close()
  201. }
  202. /**
  203. *
  204. * @param {import('puppeteer').Browser} browser
  205. * @param {boolean} isVapor
  206. */
  207. async function doBench(browser, isVapor) {
  208. const mode = isVapor ? 'vapor' : 'vdom'
  209. console.info('\n\nmode:', mode)
  210. const page = await browser.newPage()
  211. page.emulateCPUThrottling(4)
  212. await page.goto(`http://localhost:${port}/${mode}`, {
  213. waitUntil: 'networkidle0',
  214. })
  215. await forceGC()
  216. const t = performance.now()
  217. console.log('warmup run')
  218. await eachRun(() => withoutRecord(benchOnce), warmupCount)
  219. console.log('benchmark run')
  220. await eachRun(benchOnce, count)
  221. console.info(
  222. 'Total time:',
  223. colors.cyan(((performance.now() - t) / 1000).toFixed(2)),
  224. 's',
  225. )
  226. const times = await getTimes()
  227. const result =
  228. /** @type {Record<string, typeof compute>} */
  229. Object.fromEntries(Object.entries(times).map(([k, v]) => [k, compute(v)]))
  230. console.table(result)
  231. await writeFile(
  232. `results/benchmark-${sha}-${mode}.json`,
  233. JSON.stringify(result, undefined, 2),
  234. )
  235. await page.close()
  236. return result
  237. async function benchOnce() {
  238. await clickButton('run') // test: create rows
  239. await clickButton('update') // partial update
  240. await clickButton('swaprows') // swap rows
  241. await select() // test: select row, remove row
  242. await clickButton('clear') // clear rows
  243. await withoutRecord(() => clickButton('run'))
  244. await clickButton('add') // append rows to large table
  245. await withoutRecord(() => clickButton('clear'))
  246. await clickButton('runLots') // create many rows
  247. await withoutRecord(() => clickButton('clear'))
  248. // TODO replace all rows
  249. }
  250. function getTimes() {
  251. return page.evaluate(() => /** @type {any} */ (globalThis).times)
  252. }
  253. async function forceGC() {
  254. await page.evaluate(
  255. `window.gc({type:'major',execution:'sync',flavor:'last-resort'})`,
  256. )
  257. }
  258. /** @param {() => any} fn */
  259. async function withoutRecord(fn) {
  260. const currentRecordTime = await page.evaluate(() => globalThis.recordTime)
  261. await page.evaluate(() => (globalThis.recordTime = false))
  262. await fn()
  263. await page.evaluate(
  264. currentRecordTime => (globalThis.recordTime = currentRecordTime),
  265. currentRecordTime,
  266. )
  267. }
  268. /** @param {string} id */
  269. async function clickButton(id) {
  270. await page.click(`#${id}`)
  271. await wait()
  272. }
  273. async function select() {
  274. for (let i = 1; i <= 10; i++) {
  275. await page.click(`tbody > tr:nth-child(2) > td:nth-child(2) > a`)
  276. await page.waitForSelector(`tbody > tr:nth-child(2).danger`)
  277. await page.click(`tbody > tr:nth-child(2) > td:nth-child(3) > button`)
  278. await wait()
  279. }
  280. }
  281. async function wait() {
  282. await page.waitForSelector('.done')
  283. }
  284. }
  285. /**
  286. * @param {Function} bench
  287. * @param {number} count
  288. */
  289. async function eachRun(bench, count) {
  290. for (let i = 0; i < count; i++) {
  291. readline.cursorTo(process.stdout, 0)
  292. readline.clearLine(process.stdout, 0)
  293. process.stdout.write(`${i + 1}/${count}`)
  294. await bench()
  295. }
  296. if (count === 0) {
  297. process.stdout.write('0/0 (skip)')
  298. }
  299. process.stdout.write('\n')
  300. }
  301. async function initBrowser() {
  302. const disableFeatures = [
  303. 'Translate', // avoid translation popups
  304. 'PrivacySandboxSettings4', // avoid privacy popup
  305. 'IPH_SidePanelGenericMenuFeature', // bookmark popup see https://github.com/krausest/js-framework-benchmark/issues/1688
  306. ]
  307. const args = [
  308. '--js-flags=--expose-gc', // needed for gc() function
  309. '--no-default-browser-check',
  310. '--disable-sync',
  311. '--no-first-run',
  312. '--ash-no-nudges',
  313. '--disable-extensions',
  314. `--disable-features=${disableFeatures.join(',')}`,
  315. ]
  316. const headless = !noHeadless
  317. console.info('headless:', headless)
  318. const browser = await launch({
  319. headless: headless,
  320. args,
  321. })
  322. console.log('browser version:', colors.blue(await browser.version()))
  323. return browser
  324. }
  325. /** @param {number[]} array */
  326. function compute(array) {
  327. const n = array.length
  328. const max = Math.max(...array)
  329. const min = Math.min(...array)
  330. const mean = array.reduce((a, b) => a + b) / n
  331. const std = Math.sqrt(
  332. array.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n,
  333. )
  334. const median = array.slice().sort((a, b) => a - b)[Math.floor(n / 2)]
  335. return {
  336. max: round(max),
  337. min: round(min),
  338. mean: round(mean),
  339. std: round(std),
  340. median: round(median),
  341. }
  342. }
  343. /** @param {number} n */
  344. function round(n) {
  345. return +n.toFixed(2)
  346. }
  347. /** @param {number} port */
  348. function printPort(port) {
  349. const vaporLink = !noVapor
  350. ? `\nVapor: ${colors.blue(`http://localhost:${port}/vapor`)}`
  351. : ''
  352. const vdomLink = vdom
  353. ? `\nvDom: ${colors.blue(`http://localhost:${port}/vdom`)}`
  354. : ''
  355. console.info(`\n\nServer started at`, vaporLink, vdomLink)
  356. }