Vite 原理解析系列之 Vite 启动流程解析

1,649 阅读9分钟

前言

     笔者几个月前已经发表了两篇关于Vite原理的文章:《Vite2.0原理解析之import路径重写机制》《Vite原理解析系列之基于原生 ESM 的 HMR 实现》。本文将继续对 Vite 中一些核心模块的原理进行解析,让大家对 Vite 有一个更深入的理解。这次我们将对 Vite 启动流程进行详细解析,通过了解 Vite 的启动流程,我们可以初窥到 Vite 的‘全貌’。

Vite 启动时做了什么,实现了哪些服务

别看 Vite 启动非常快,其实它启动时做的事还真不少。这里我在下面给大家简要的列一下其启动时做了啥:

  • 用户配置解析
  • 执行依赖预构建(第一次启动或有依赖变更时)
  • 启动文件监听,实现 HMR
  • 挂载必要中间件和插件,实现一些增强功能(CSS 预处理器,TS转译等)
  • 启动 node http server 服务器,提供基础静态文件服务

最终,一个Vite服务实现的功能如下所示:

为什么需要了解启动流程

Vite 作为一种新型的前端构建工具,其官网首推的最具吸引力的特性如下:

Vite 最具吸引力的特性之一就是它令人惊叹的服务启动速度,大多数时候从输入启动命令回车到 dev server ready用时一般都不到 2s。如果想要了解 Vite 启动快的秘诀,解析其启动流程也是最直接有效的方法。另外如果你了解 webpack 的启动流程,那么你会发现 Vite 的启动流程相比于 webpack 还是有着显著的区别的,还是非常值得一探的。最后在了解了Vite的启动流程之后,今后在使用 Vite 时遇到与其启动有关的问题也可以快速明确问题的原因并解决它。

Vite启动流程解析

启动流程总览

下面是 Vite 启动流程,当我们在命令行中输入npm run dev 时,一个 Vite 服务就开始了,总体流程如下图所示:

这块逻辑实际就执行了两个操作,创建 server 对象并执行它的 listen 方法。后面我会按照这两个操作分别进行解析。详细实现如下所示,这部分完整代码传送门 cli.ts


// dev
cli
  .command('[root]'// default command
···
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle`
  )
  .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
    // output structure is preserved even after bundling so require()
    // is ok here
    const { createServer } = await import('./server')
    try {
			// 创建server对象
      const server = await createServer({
        root,
        base: options.base,
        mode: options.mode,
        configFile: options.config,
        logLevel: options.logLevel,
        clearScreen: options.clearScreen,
        server: cleanOptions(options) as ServerOptions
      })
			// 启动服务
      await server.listen()
    } catch (e) {
      ···
    }
  })

详细流程解析

一、创建server实例

首先,脚手架会先执行用于创建server实例的 createServer 方法,这块也是 Vite 启动流程的重头戏。下面我会按照其功能逻辑分为6步,逐一进行解析。

1.配置解析汇总

在 Vite 启动流程的第一步,是将用户定义的配置、Vite 默认的配置以及命令行定义的配置进行合并汇总,最终获得一个合并后的完整的 config 对象,后续的大多数操作都会基于这个 config 进行。具体实现代码如下:

const config = await resolveConfig(inlineConfig, 'serve''development')
const root = config.root //项目根路经
const serverConfig = config.server //server相关配置
const httpsOptions = await resolveHttpsConfig(config) // 获取 https.createServer() 的选项对象

这块的核心逻辑都在 resolveConfig 方法中,这个方法会找到用户的配置文件读取配置,并判断当前的 command 是 serve 还是 build 来最终合并汇总出最终的 Vite 的配置,具体是实现逻辑这里就不进行详细展开了,有兴趣的同学可以参考 resolveConfig 源码 config.ts。具体的 Vite 配置项可以参考Vite官方配置文档。为了方便大家对生成的配置对象有一个直观的概念,我这里放一个生成的 config 对象的一个 demo 给大家参考下:

{
  plugins: [
    {
      name'vite:pre-alias',
      configureServer: [Function: configureServer],
      resolveId: [Function: resolveId]
    },
     ···
    {
      name'vite:import-analysis',
      configureServer: [Function: configureServer],
      transform: [AsyncFunction: transform]
    }
  ],
  server: {
    port7001,
    fsServe: {
      root'/Users/jerry/github-project/xxx-admin',
      strictfalse
    }
  },
  css: {
    preprocessorOptions: { less: [Object] },
    modules: { scopeBehaviour'local' }
  },
  build: {
    target: [ 'es2019''edge88''firefox78''chrome87''safari13.1' ],
    polyfillDynamicImportfalse,
    outDir'dist',
    assetsDir'assets',
    assetsInlineLimit4096,
    cssCodeSplitfalse,
    sourcemapfalse,
    rollupOptions: { output: [Object] },
    commonjsOptions: { include: [Array], extensions: [Array] },
    minify'esbuild',
    terserOptions: {},
    cleanCssOptions: {},
    writetrue,
    emptyOutDirtrue,
    manifestfalse,
    libfalse,
    ssrfalse,
    ssrManifestfalse,
    brotliSizetrue,
    chunkSizeWarningLimit800,
    watchnull
  },
  optimizeDeps: { esbuildOptions: { keepNamesundefined } },
  resolve: { dedupeundefinedalias: [ [Object], [Object] ] },
  configFile'/Users/jerry/github-project/xxx-admin/vite.config.ts',
  configFileDependencies: [ 'vite.config.ts' ],
  inlineConfig: {
    rootundefined,
    baseundefined,
    modeundefined,
    configFileundefined,
    logLevelundefined,
    clearScreenundefined,
    server: {}
  },
  root'/Users/jerry/github-project/xxx-admin',
  base'/',
  publicDir'/Users/jerry/github-project/xxx-admin/public',
  cacheDir'/Users/jerry/github-project/xxx-admin/node_modules/.vite',
  command'serve',
  mode'development',
  isProductionfalse,
  env: { BASE_URL'/'MODE'development'DEVtruePRODfalse },
  assetsInclude: [Function: assetsInclude],
  logger: {
    hasWarnedfalse,
    info: [Function: info],
    warn: [Function: warn],
    warnOnce: [Function: warnOnce],
    error: [Function: error],
    clearScreen: [Function: clearScreen]
  },
  createResolver: [Function: createResolver]
}

2.定义并初始化ViteDevServer对象以及相关的各类基础实例(ws、connect、watcher等)

上一步我们有了配置之后,接下来就是各种实例的初始化操作。这一步我们会先初始化中间件、node http server实例,web socket实例、chokidar 监听器实例等,同时还会创建 Vite 插件容器用于承载其功能强大的内置插件,具体逻辑如下:

	// 生成 Connect 框架存储中间件的对象
  const middlewares connect() as Connect.Server
  // 判断是否为中间件模式,新建 http server 服务对象
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(serverConfig, middlewares, httpsOptions)
  // 根据配置创建websocket服务
  const ws createWebSocketServer(httpServer, config, httpsOptions)
  // 根据配置生成watcher实现对文件系统的监听
  const { ignored = [], ...watchOptions } = serverConfig.watch || {}
  const watcher = chokidar.watch(path.resolve(root), {
    ignored: ['**/node_modules/**''**/.git/**', ...ignored],
    ignoreInitialtrue,
    ignorePermissionErrorstrue,
    disableGlobbingtrue,
    ...watchOptions
  }) as FSWatcher
  // 新建插件容器、模块图等vite dev server必备模块
  const plugins = config.plugins
  const container await createPluginContainer(config, watcher)
  const moduleGraph new ModuleGraph(container)
  const closeHttpServer createServerCloseFn(httpServer)

当上面的这些 Vite dev server 必备的实例对象初始化完毕之后,就会输出到 Vite dev server 对象上的相应字段上,最终构建出一个完整的 Vite dev server对象。从 Vite 官方文档中可以获取到 Vite dev server 的详细 API 说明

下面是在Vite源码中,一个完整的 Vite dev server对象:

const server: ViteDevServer = {
    /**
     * 被解析的 vite 配置对象
     */
    config: config,
    /**
     * 一个 connect 应用实例
     * - 可以用于将自定义中间件附加到开发服务器。
     * - 还可以用作自定义http服务器的处理函数。
        或作为中间件用于任何 connect 风格的 Node.js 框架。
      *
      * https://github.com/senchalabs/connect#use-middleware
      */
    middlewares,
    get app() {
      config.logger.warn(
        `ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`
      )
      return middlewares
    },
    /**
     * 本机 node http 服务器实例
     */
    httpServer,
    /**
     * chokidar 监听器实例
     * https://github.com/paulmillr/chokidar#api
     */
    watcher,
    /**
     * Rollup 插件容器,可以针对给定文件运行插件钩子。
     */
    pluginContainer: container,
    /**
     * web socket 服务器,带有 `send(payload)` 方法。
     */
    ws,
    /**
     * 跟踪导入关系、url 到文件映射和 hmr 状态的模块图。
     */
    moduleGraph,
    /**
     * 使用 esbuild 转换一个文件的工具函数
     * 对某些特定插件十分有用
     */
    transformWithEsbuild,
    /**
     * 以代码方式解析、加载和转换 url 并获取结果
     * 而不需要通过 http 请求管道。
     */
    transformRequest(url, options) {
      return transformRequest(url, server, options)
    },
    /**
     * 应用 vite 内建 HTML 转换和任意插件 HTML 转换
     */
    transformIndexHtml: null as any,
    /**
     * 加载一个给定的 URL 作为 SSR 的实例化模块
     */
    ssrLoadModule(url) {
      if (!server._ssrExternals) {
        server._ssrExternals = resolveSSRExternal(
          config,
          server._optimizeDepsMetadata
            ? Object.keys(server._optimizeDepsMetadata.optimized)
            : []
        )
      }
      return ssrLoadModule(url, server)
    },
    /**
     * 解决 ssr 错误堆栈信息
     */
    ssrFixStacktrace(e) {
      if (e.stack) {
        e.stack = ssrRewriteStacktrace(e.stack, moduleGraph)
      }
    },
    /**
     * 启动服务器
     */
    listen(port?: number, isRestart?: boolean) {
      return startServer(server, port, isRestart)
    },
    /**
     * 停止服务器
     */
    async close() {
      process.off('SIGTERM', exitProcess)

      if (!process.stdin.isTTY) {
        process.stdin.off('end', exitProcess)
      }

      await Promise.all([
        watcher.close(),
        ws.close(),
        container.close(),
        closeHttpServer()
      ])
    },
    _optimizeDepsMetadata: null,
    _ssrExternals: null,
    _globImporters: {},
    _isRunningOptimizer: false,
    _registerMissingImport: null,
    _pendingReload: null
  }

3.绑定必要监听事件,支持HMR服务

接下来,开始进行一些监听事件的绑定,以实现HMR功能,这里的核心逻辑就是在触发change事件时,更新 moduleGraph 中的缓存,触发热更新方法 handleHMRUpdate 实现模块热更新。关于Vite热更新实现原理,这里就不进行详细展开说明了,后续会有文章进行专门的解析。这块的具体实现如下:

 // 绑定监听事件
  watcher.on('change'async (file) => {
    file = normalizePath(file)
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    if (serverConfig.hmr !== false) {
      try {
				// 执行热更新逻辑
        await handleHMRUpdate(file, server)
      } catch (err) {
        ws.send({
          type: 'error',
          err: prepareError(err)
        })
      }
    }
  })

  watcher.on('add', (file) => {
    handleFileAddUnlink(normalizePath(file), server)
  })

  watcher.on('unlink', (file) => {
    handleFileAddUnlink(normalizePath(file), server, true)
  })

4.执行插件列表中每个插件的 configureServer 钩子

从 Vite 官网,我们了解到 configureServer 是 Vite 插件独有的一个钩子,主要被用于注入自定义中间件以及存储 vite dev server对象,详情可以参考这里。值得注意的是,这个方法是在 Vite 挂载内置中间件之前执行的。为了能够实现注入后置中间件的功能,这里定义了一个 postHooks 数组用于存储方法返回值,这个数组会在内置中间件挂载完成之后进行遍历,当返回值是一个函数时,执行该函数。具体实现代码如下:

	// apply server configuration hooks from plugins
  const postHooks: ((() => void) | void)[] = []
  for (const plugin of plugins) {
    if (plugin.configureServer) {
      postHooks.push(await plugin.configureServer(server))
    }
  }
// 第5步逻辑--挂载中间件
...

 postHooks.forEach((fn) => fn && fn())

5.挂载内置中间件

这一步主要是对Vite的一些内置的中间件进行挂载。此处挂载的 Vite 内置中间件如下:

这些中间件命名都十分规范,基本都可以通过其名称知道其大致的用途,这里就不对每个中间件进行详细展开了,后续会有相关文章对一些核心的中间件进行解读。这一步主要就是使用之前调用 connect() 方法返回的 middlewares 实例,根据配置进行中间件的 use 操作,其具体逻辑如下:

	// cors (enabled by default)
  const { cors } = serverConfig
  if (cors !== false) {
    middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
  }

  // proxy
  const { proxy } = serverConfig
  if (proxy) {
    middlewares.use(proxyMiddleware(httpServer, config))
  }

  // base
  if (config.base !== '/') {
    middlewares.use(baseMiddleware(server))
  }

  // open in editor support
  middlewares.use('/__open-in-editor'launchEditorMiddleware())

  // hmr reconnect ping
  // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
  middlewares.use('/__vite_ping', function viteHMRPingMiddleware(_, res) {
    res.end('pong')
  })

  //decode request url
  middlewares.use(decodeURIMiddleware())

  // serve static files under /public
  // this applies before the transform middleware so that these files are served
  // as-is without transforms.
  if (config.publicDir) {
    middlewares.use(servePublicMiddleware(config.publicDir))
  }

  // main transform middleware
  middlewares.use(transformMiddleware(server))

  // serve static files
  middlewares.use(serveRawFsMiddleware(config))
  middlewares.use(serveStaticMiddleware(root, config))

  // spa fallback
  if (!middlewareMode || middlewareMode === 'html') {
    middlewares.use(
      history({
        loggercreateDebugger('vite:spa-fallback'),
        // support /dir/ without explicit index.html
        rewrites: [
          {
            from//$/,
            to({ parsedUrl }: any) {
              const rewritten = parsedUrl.pathname + 'index.html'
              if (fs.existsSync(path.join(root, rewritten))) {
                return rewritten
              } else {
                return `/index.html`
              }
            }
          }
        ]
      })
    )
  }

  if (!middlewareMode || middlewareMode === 'html') {
    // transform index.html
    middlewares.use(indexHtmlMiddleware(server))
    // handle 404s
    // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
    middlewares.use(function vite404Middleware(_, res) {
      res.statusCode = 404
      res.end()
    })
  }

  // error handler
  middlewares.use(errorMiddleware(server, !!middlewareMode))

6.绑定预构建方法和插件的buildStart钩子

最后,将执行预构建的方法 runOptimize() 和插件的 buildStart 钩子绑定到 httpServer.listen 上,让预构建和插件的 buildStart 钩子能够在服务器启动之前执行。具体实现逻辑如下:

  //	执行预构建
	const runOptimize = async () => {
    if (config.cacheDir) {
      server._isRunningOptimizer = true
      try {
        server._optimizeDepsMetadata = await optimizeDeps(config)
      } finally {
        server._isRunningOptimizer = false
      }
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
  }
	// 判断是否为中间件模式
  if (!middlewareMode && httpServer) {
    // overwrite listen to run optimizer before server start
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (portnumber, ...argsany[]) => {
      try {
				// 执行插件钩子和预构建
        await container.buildStart({})
        await runOptimize()
      } catch (e) {
        httpServer.emit('error', e)
        return
      }
      return listen(port, ...args)
    }) as any

    httpServer.once('listening'() => {
      // update actual port since this may be different from initial value
      serverConfig.port = (httpServer.address() as AddressInfo).port
    })
  } else {
    await container.buildStart({})
    await runOptimize()
  }

  return server

关于预构建

上面提到了'预构建',或许一些同学不太了解何谓’预构建‘,这里做一个简单的解释:

Vite的依赖预构建又称依赖预优化,它主要干了两件事,一是将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM,这是因为在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。二是将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。后续会有文章专门对 Vite 依赖预构建的原理信息解读,大家可以关注下。

关于middlewareMode

上面解析流程中多次出现了 middlewareMode ,如果这个字段设置为非false的值,将会以中间件模式创建 Vite 服务器。这个模式主要用于SSR场景,目前还处于实验阶段,本文暂不作展开。

二、启动服务监听

在服务对象创建完毕之后,就会调用 server.listen() 由于在上面的步骤中绑定了执行预构建的方法 runOptimize和 buildStart,因此调用 listen 方法时,会先执行Vite插件 buildStart 钩子和依赖预构建方法,然后再启动服务。

下面是 Vite 服务启动的逻辑:

// 服务启动方法
async function startServer(
  server: ViteDevServer,
  inlinePort?: number,
  isRestart: boolean = false
): Promise<ViteDevServer> {
  const httpServer = server.httpServer
  if (!httpServer) {
    throw new Error('Cannot call server.listen in middleware mode.')
  }
	// 获取必要配置参数
  const options = server.config.server
  let port = inlinePort || options.port || 3000
  const hostname = resolveHostname(options.host)

  const protocol = options.https ? 'https' : 'http'
  const info = server.config.logger.info
  const base = server.config.base

  return new Promise((resolve, reject) => {
		// 端口占用错误反馈
    const onError = (e: Error & { code?: string }) => {
      if (e.code === 'EADDRINUSE') {
        if (options.strictPort) {
          httpServer.removeListener('error', onError)
          reject(new Error(`Port ${port} is already in use`))
        } else {
          info(`Port ${port} is in use, trying another one...`)
          httpServer.listen(++port, hostname.host)
        }
      } else {
        httpServer.removeListener('error', onError)
        reject(e)
      }
    }

    httpServer.on('error', onError)

    httpServer.listen(port, hostname.host() => {
      httpServer.removeListener('error', onError)
      // 打印服务器地址、端口号等
      info(
        chalk.cyan(`\n  vite v${require('vite/package.json').version}`) +
          chalk.green(` dev server running at:\n`),
        {
          clear: !server.config.logger.hasWarned
        }
      )

      printServerUrls(hostname, protocol, port, base, info)
      // 打印服务启动时间
      // @ts-ignore
      if (global.__vite_start_time) {
        info(
          chalk.cyan(
            // @ts-ignore
            `\n  ready in ${Date.now() - global.__vite_start_time}ms.\n`
          )
        )
      }

      // @ts-ignore
      const profileSession = global.__vite_profile_session
      if (profileSession) {
        profileSession.post('Profiler.stop'(err: any, { profile }: any) => {
          // Write profile to disk, upload, etc.
          if (!err) {
            const outPath = path.resolve('./vite-profile.cpuprofile')
            fs.writeFileSync(outPath, JSON.stringify(profile))
            info(
              chalk.yellow(
                `  CPU profile written to ${chalk.white.dim(outPath)}\n`
              )
            )
          } else {
            throw err
          }
        })
      }
      // 自动打开浏览器逻辑
      if (options.open && !isRestart) {
        const path = typeof options.open === 'string' ? options.open : base
        openBrowser(
          `${protocol}://${hostname.name}:${port}${path}`,
          true,
          server.config.logger
        )
      }

      resolve(server)
    })
  })
}

总结

从上面的流程解析中,相信大家可以很明显的发现,Vite 在启动流程中并没有任何与编译打包有关的步骤,相较于传统类构建工具(webpack)大大节省了启动时间,这或许就是其快的秘诀之一。Vite 代表的不只是一个框架,更是一种趋势。笔者在写本文时,已经实现了一个中后台系统的 Vite 落地,明确了在实际项目中应用 Vite 构建工具,确实能够很大的提升开发体验和研发效率。相信在不远的未来,前端构建工具一定会经历一次’改朝换代‘。