当前位置: 首页 > 工具软件 > Go Vite > 使用案例 >

Vite原理学习之预编译

詹正浩
2023-12-01

前言

Vite是下一代的前端开发与构建工具,为什么称为下一代?根本原因在于其基于原生ES Module。在目前的前端工程化生态中,webpack、rollup、esbuild等非常流行,而Vite真是构建在一些流行的技术上。Vite的出现实际上是前端模块规范发展到现在自然出现的产物,它并不是首个基于ES Module的构建工具,还有一些同类型的工具例如Snowpack、@web/dev-server等等。

Vite说明

Vite是基于ES Module的构建工具,可与webpack做对比来学习了解。从构建工具的角度来看前端发展,目前可以简单分为三个阶段:

  • 第一阶段:IIFE实现模块化,源码修改后手动刷新代码来查看最新的效果
  • 第二阶段:CommonJs、AMD、ES Module等模块化规范发展起来,出现了一些构建工具例如webpack,前后端分离促进前端工程化的发展
  • 第三阶段:ES Module支持程度增大,基于ES Module的构建工具例如vite、snowpack等渐渐发展

前端项目越来越大,你会发现webpack等传统构建工具的项目启动速度、HMR速度是越来越慢,Vite实际上就是来解决这两个问题的。基于ES Module,Vite是如何加快开发阶段项目启动和更新呢?

Vite将应用模块分为两类:依赖和源码:

  • 依赖是指第三方模块即node_modules中模块
  • 源码是指非node_modules中本项目源代码

在项目启动时,Vite使用esbuild来预编译代码中使用的依赖包,但是不会对源码做任何处理,一方面得益于Go语言编写的esbuild的编译速度,这个过程相对webpack的JS语言编写的acorn编译非常快,另一方面减少编译的代码量,从而大大提高了项目的启动速度。并且在预编译后还添加了缓存机制,避免再次启动的不必要编译。

在开发过程中,Vite才开始对源码进行必要的转换和动态更新。这个过程是按需进行的,只有当浏览器需要相关模块源码时才会执行,这个过程Vite会通过相关机制来实现。按需更新保证了在开发阶段HMR的速度问题,从而带来良好的开发体验。

实际上Vite背后的原理主要就是这两点实现的逻辑处理,当然还涉及到一些不同规范模块转换、CSS分割、ESM特点导致的其他兼容处理等等一系列工作。

Vite项目启动过程

vite官方提供了创建基于vite的项目开发的脚手架create-vite-app,其内置常见的MVVM框架的项目模板,例如vue、vue + typescript、react等。

"scripts": {
	"dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

基于Vite 2.6.7版本创建的,开发环境的命令直接就是执行vite命令。在源码中vite命令对应的逻辑总结如下:

// vite/bin/vite.js

// --debug调试参数,会在终端打印出vite相关细节
const debugIndex = process.argv.findIndex((arg) => /^(?:-d|--debug)$/.test(arg))
if (debufIndex > 0) { // 相关逻辑 }
// --profile统计分析参数,会生成profile文件
const profileIndex = process.argv.indexOf('--profile')
if (profileIndex > 0) {
	// 相关逻辑
} else {
	require('../dist/node/cli')
}

vite命令最核心的逻辑是执行相关目录下的cli文件,该文件逻辑主要如下:

const cac = require('cac)
...
const cli = cac('vite')
...
// dev命令
cli
  .command('[root]') // default command
  .alias('serve') // the command is called 'serve' in Vite's API
  .alias('dev') // alias to align with the script name
  .action(async() => {
  	// 命令对应的逻辑
  })
  
// build命令
cli
  .command('build [root]')
  .action(async() => {
  	// 命令对应的逻辑
  })
  
// preview命令
cli
  .command('preview [root]')
  .action(async() => {
  	// 命令对应的逻辑
  })

实际上就是使用cac库来创建CLI工具,定义了dev、build、preview命令具体的执行逻辑。项目启动和开发阶段更新都是在dev命令的逻辑下面,其action逻辑实际上主要就是创建服务器并进行监听:

const { createServer } = await import('./server')
const server = await createServer({
	root,
    base: options.base,
    mode: options.mode,
    configFile: options.config,
    logLevel: options.logLevel,
    clearScreen: options.clearScreen,
    server: cleanOptions(options)
})
if (!server.httpServer) {
	throw new Error('HTTP server not available')
}
await server.listen()

依赖预构建逻辑都在创建服务器的相关文件中,即调用node/server下文件对应的createServer函数来创建开发服务器。

createServer创建开发服务器

createServer函数主要的逻辑主要以下几点:

  • 合并配置文件和相关默认值生成最终的配置对象
  • 根据不同的配置应用不同的中间件
  • 重写开发服务器listen方法执行预编译
  • 使用chokidar库创建监听器来监听相关文件变动执行不同的逻辑
根据配置应用中间件

在Vite源码中相关配置对应的逻辑是通过中间件来实现的:

// 默认内置的中间件
const middlewares = connect() as Connect.Server

中间件实际上是Node的Connect(中间件框架)的实例, 通过Connect框架来实现应用自定义中间件或服务器。在源码中针对一些配置项应用相关内置的中间件,这里简单列举一些:

  // request timer
  if (process.env.DEBUG) {
    middlewares.use(timeMiddleware(root))
  }
  // 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))
  }

其中一些重要的中间件有:

  • servePublicMiddleware:处理public的
  • serveStaticMiddleware:处理serve root路径下静态文件相关
  • serveRawFsMiddleware:处理链接root之外的相关静态文件相关,例如多项目情况
  • spaFallbackMiddleware:html spa处理
  • indexHtmlMiddleware:html转换
  • transformMiddleware:源码转换,当HMR时处理执行
  • errorMiddleware:全局错误相关
servePublicMiddleware中间件逻辑

该中间件逻辑如下:

export function servePublicMiddleware(dir: string): Connect.NextHandleFunction {
  const serve = sirv(dir, sirvOptions)
  return function viteServePublicMiddleware(req, res, next) {
    // 跳过相关静态资源的请求 skip import request and internal requests `/@fs/ /@vite-client` etc
    if (isImportRequest(req.url!) || isInternalRequest(req.url!)) {
      return next()
    }
    serve(req, res, next)
  }
}

sirv是一个轻量级的中间件,用于处理对静态资源的请求,具体使用可以去看相关npm sirv文档

  // 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))
  }

publicDir默认是public,作为静态资源服务的文件夹,该文件夹下内容不会被编译转换。serveStaticMiddleware和serveRawFsMiddleware中间件虽然处理不同的静态资源获取的情况,但是底层都是使用sirv来实现的,这里就不展开说明了。

spaFallbackMiddleware中间件逻辑

该中间件的应用是有前提条件的,即:

  if (!middlewareMode || middlewareMode === 'html') {
    middlewares.use(spaFallbackMiddleware(root))
  }

middlewareMode支持布尔值和字符串,该属性是serve配置项:

以中间件模式创建 Vite 服务器。(不含 HTTP 服务器)

  • ‘ssr’ 将禁用 Vite 自身的 HTML 服务逻辑,因此你应该手动为 index.html 提供服务。
  • ‘html’ 将启用 Vite 自身的 HTML 服务逻辑。

当该属性值是ssr和是布尔值true是等价, 该属性可以不配置默认该值是false。spaFallbackMiddleware中间件实际上是针对index.html文件访问地址的处理:

import history from 'connect-history-api-fallback'

export function spaFallbackMiddleware(
  root: string
): Connect.NextHandleFunction {
  const historySpaFallbackMiddleware = history({
    // support /dir/ without explicit index.html
    rewrites: [
      {
        from: /\/$/,
        to({ parsedUrl }: any) {
          const rewritten =
            decodeURIComponent(parsedUrl.pathname) + 'index.html'

          if (fs.existsSync(path.join(root, rewritten))) {
            return rewritten
          } else {
            return `/index.html`
          }
        }
      }
    ]
  })

SPA项目使用History API的路由方式当刷新时对于不匹配的路由会找不到对应的文件,使用connect-history-api-fallback来解决相关文件,vite在该中间中对于访问index.html的一些路径支持。

indexHtmlMiddleware中间件逻辑
  if (!middlewareMode || middlewareMode === 'html') {
    // transform index.html
    middlewares.use(indexHtmlMiddleware(server))
  }

该中间的逻辑就是转换index.html的内容,应用针对htm内容的一些插件操作html对应的文本内容生成最终的内容,逻辑简单概括两步:

  • 同步读取html内容生成
  • 读取配置的插件列表并将HTML内容传递给它们,生成最后HTML内容

开发阶段的CSS插入、组件插入等等都是在此中间件执行的。

预编译

vite重写了Node服务器listen方法来在服务器启动完成前执行预编译动作,具体逻辑如下:

  const runOptimize = async () => {
    if (config.cacheDir) {
      server._isRunningOptimizer = true
      try {
        server._optimizeDepsMetadata = await optimizeDeps(
          config,
          config.server.force || server._forceOptimizeOnRestart
        )
      } finally {
        server._isRunningOptimizer = false
      }
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
  }

  if (!middlewareMode && httpServer) {
    let isOptimized = false
    // overwrite listen to run optimizer before server start
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      if (!isOptimized) {
        try {
          await container.buildStart({})
          await runOptimize()
          isOptimized = true
        } catch (e) {
          httpServer.emit('error', e)
          return
        }
      }
      return listen(port, ...args)
    }) as any
  } else {
    await container.buildStart({})
    await runOptimize()
  }

从上面逻辑可知,核心逻辑是调用optimizeDeps,而该方法逻辑简单总结如下几点:

  • 创建缓存目录以及相关文件:默认缓存目录是node_modules/.vite,会创建_metadata.json、package.json、编译后的文件和其对应sourcemap文件等
  • 从入口文件扫描收集依赖
  • 使用esbuild预编译相关依赖为ESM格式
收集依赖

收集依赖就要有入口,默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项。如果指定了config.optimizeDeps.entries、 build.rollupOptions.input,Vite 将转而去抓取这些入口点。
获取到入口之后就调用esbuild的build API来操作文件系统中文件,具体代码如下:

  await Promise.all(
    entries.map((entry) =>
      build({
        absWorkingDir: process.cwd(),
        write: false,
        entryPoints: [entry],
        bundle: true,
        format: 'esm',
        logLevel: 'error',
        plugins: [...plugins, plugin],
        ...esbuildOptions
      })
    )

从入口文件开始遍历所有文件找到符合要求的import语句,获取对应的依赖。并不是所有import语句对应的模块都是依赖,这个过程会筛选符合要求的import语句,具体是通过esbuildScanPlugin生成的插件逻辑来决定的。esbuildScanPlugin插件会定义相关的过滤条件,这边就涉及到esbuild插件相关知识。
具体的扫描过程相对复杂繁琐,不过有一个非常重要的工具库es-module-lexer,该库用于ES模块语法词法分析。

编译依赖

当获取到符合要求的依赖后,就逐个对依赖进行编译,并输出到缓存目录.vite中,这个过程涉及到esbuild、es-module-lexer相关使用。

总结

Vite预编译过程从源码来看还是非常清晰的,其中涉及到esbuild、es-module-lexer、fast-glob等相关库的使用逻辑相对又非常复杂,涉及到编译原理。作为可用于实际开发的工具,其细节还是非常多的,本文仅仅在流程上大概梳理下预编译阶段的主要过程点。预编译过程整体如下:

  • 处理命令支持的debug、profile参数
  • 使用cac定制cli对象,用于定制dev、serve、build、preview等命令的具体处理逻辑,默认vite就是执行dev命令
  • 执行dev命令,会调用createServer来创建开发服务器,并启动监听相关端口

开发服务器创建过程逻辑主要如下:

  • 合并配置文件和相关默认值生成最终的配置对象

  • 根据不同的配置添加不同的中间件,用于在更新阶段做相关处理

  • 重写开发服务器listen方法执行预编译方法,主要就是调用optimizeDeps方法

    • 创建.vite缓存目录并创建相关编译的依赖文件等等
    • 从入口文件扫描收集依赖,入口文件默认是index.html,可通过config.optimizeDeps.entries、 build.rollupOptions.input来指定,优先级大于默认的
    • 使用esbuild预编译相关依赖为ESM格式,所有依赖都是编译成ES Module规范的,依赖可能是IIFE、CommonJs等
  • 使用chokidar库创建监听器来监听相关文件变动执行不同的逻辑以及创建webscoket连接

 类似资料: