Vitest 源码解读(一)———— vite-node

vite-node 是一个 npm 库,用于在node 环境中使用 vite, 这个库在 vitest 中使用,因此了解 vite-node 会更有助于理解 vitest。

vite-node

既然在 node 环境中应用,那么客户端就不再是浏览器,而是 node 端,所以 vite-node 主要是提供了一个可以运行在 node 侧的客户端环境,即 ViteNodeRunner, 使客户端和server端可以在同一 node 环境上运行,接下来我们来看一下 viteNodeRunner 是如何实现的:

class ViteNodeRunner {
  // 在浏览器中, 会直接加载模块,但在 node 中,需要手动加载模块
  async executeFile(file: string) {
    return await this.directRequest(file, [])
  }

  async directRequest(id, fsPath, callstack) {
    // 调用 server 的 fetchModule 函数,用于转换源码,转换逻辑与 vite 相同
    const { code: transformed } = await this.options.fetchModule(id)
    // 构建上下文对象
    const exports = {}
    const context = this.prepareContext({
      // esm transformed by Vite
      __vite_ssr_import__: directRequest,
      __vite_ssr_dynamic_import__: directRequest,
      __vite_ssr_exports__: exports,
      __vite_ssr_exportAll__: (obj: any) => exportAll(exports, obj),
      __vite_ssr_import_meta__: { url },

      // cjs compact
      require: createRequire(url),
      exports,
      module: {
        set exports(value) {
          exportAll(exports, value)
          exports.default = value
        },
        get exports() {
          return exports.default
        },
      },
      __filename,
      __dirname: dirname(__filename),
    })

    // 在指定的上下文中执行 JavaScript 代码
    const fn = vm.runInThisContext(`async (${Object.keys(context).join(',')})=>{{${transformed}\n}}`, {
      filename: fsPath,
      lineOffset: 0,
    })

    await fn(...Object.values(context))

    return exports
  }
}

vite-node 对原 server 的逻辑也进行了封装,具体见:

class ViteNodeServer {
  // 钩子函数,用于转换源码
  async fetchModule(id: string) {
    const r = await this.transformRequest(id)
    return { code: r?.code }
  }
  private async transformRequest(id: string) {
    //直接调用 vite 的 transformRequest 函数
    return await this.server.transformRequest(id, { ssr: true })
  }
}

定义完 ViteNodeRunner 和 ViteNodeServer 之后,我们就可以在 node 环境中使用 vite 了。使用方式示例见:

import { createServer } from 'vite'

const server = await createServer({})
await server.pluginContainer.buildStart({})

const node = new ViteNodeServer(server)

const runner = new ViteNodeRunner({
  root: server.config.root,
  base: server.config.base,
  // 作为参数传入
  fetchModule(id) {
    return node.fetchModule(id)
  },
})

// 执行
for (const file of files)
  await runner.executeFile(file)

await server.close()

vitest 中是如何接入 vite-node 的

在 vitest 中,需要多线程处理测试用例,因此需要考虑worker 线程和主线程的通信,那么结合 vite-node, vitest 是如何处理的呢?

整体流程如下, 这部分逻辑在 vitest 类的 start 函数中:

// 创建 server
// 在 config 中配置的 VitestPlugin 插件中初始化 ViteNodeServer
const server = await createServer(config)
await server.pluginContainer.buildStart({})

// 执行单测文件
await this.runFiles(files)

runFiles 函数是核心实现:


async function runFiles(files) {
  // 先等待当前正在运行的测试用例完成
  await this.runningPromise

  this.runningPromise = (async() => {
    if (!this.pool)
      this.pool = createWorkerPool(this)

    await this.pool.runTests(files, invalidates)
  })()
    .finally(() => {
      // 测试用例执行完成,重置 runningPromise
      this.runningPromise = undefined
    })

  return await this.runningPromise
}

可以看到,首先需要创建 pool, 这里 vitest 引用了 tinypool 这个库:

function createWorkerPool(ctx) {
  // 创建 worker 线程, 指定要运行的文件 worker.js
  const pool = new Tinypool({
    filename: 'dist/worker.js',
  })

  const runWithFiles = (): RunWithFiles => {
    return async(files) => {
      // 针对每一个文件,创建一个 worker 线程
      await Promise.all(files.map(async(file) => {
        const channel = new MessageChannel()
        const port = channel.port2
        const workerPort = channel.port1

        // 创建 rpc 通信通道 
        createBirpc({
          fetch(id) {
            // ViteNodeServer 提供的处理文件的方法,可供 worker 线程调用
            return ctx.vitenode.fetchModule(id)
          },
        },{
          post: v => port.postMessage(v),
          on: fn => port.addListener('message', fn),
        })

        const data = {
          port: workerPort,
          config: ctx.config,
          files: [file],
        }

        // 在工作池中,根据传入的上下文执行 worker.js 文件 的 run 函数,使用 workerPort 传递数据。
        await pool.run(data, { transferList: [workerPort], 'run' })
        port.close()
        workerPort.close()
      }))
    }
  }

  return {
    runTests: runWithFiles(),
  }
}

下面我们来看一下具体在 worker.js 中,是如何执行测试用例的:

export async function run(ctx) {
  init(ctx)
  const { run } = await startViteNode(ctx)
  return run(ctx.files, ctx.config)
}
function init(ctx) {
// 每一个 worker 线程有自己独立的 process 对象,初始化一些上下文信息
process.__vitest_worker__ = {
    ctx,
    // 创建 rpc 通道
    rpc: createBirpc<WorkerRPC>(
      {},
      {
        eventNames: ['onUserConsoleLog', 'onCollected', 'onWorkerExit'],
        post(v) { ctx.port.postMessage(v) },
        on(fn) { ctx.port.addListener('message', fn) },
      },
    ),
  }
}

function startViteNode(options) {
  // 初始化 VitestRunner 对象
  const runner = new VitestRunner({
    fetchModule(id) {
      // 通过 rpc 通道,调用 server 端提供的 fetch 方法
      return process.__vitest_worker__.rpc.fetch(id)
    },
  })

  // 执行 entry.js 文件
  return await runner.executeFile('entry.js')
}

我们再继续看一下 entry.js 文件的实现, 该函数用于具体执行测试用例:

async function run(files, config) {
  for (const file of files) {
    // 收集测试用例
    const files = await collectTests([file], config)
    // 执行测试用例
    await runSuites(files)
  }
}

具体的执行测试用例的源码,将在下一节说明。