vite 5 源码分析: chokidar 和 中间件

本文使用vite 5.1.3版本

在上文中,我们了解了httpServer 和 中间件逻辑

而本文你会学到

  • chokidarVite起到了什么作用
  • Vite针对工具集使用了怎样的缓存策略
  • Vite的中间件都做了什么
  • Vite如何处理If-None-Match请求头
  • 针对同一个css使用不同方法导入,现代浏览器普遍存在的问题,Vite如何处理的
  • Vite都在哪些中间件进行模块依赖图的构建
  • Vite访问项目目录外的安全策略
  • Vitesirv都做了什么,都从哪些目录开始提供服务

chokidar

上文我们了解到Vite使用chokidar来监控文件的变动。我们可以在createServer找到对应代码。

typescript 复制代码
  const resolvedWatchOptions = resolveChokidarOptions(config, {
    disableGlobbing: true,
    ...serverConfig.watch,
  })

resolveChokidarOptions中,会构建chokidar的配置选项。

首先,它会构建一个ignored数组,这个数组由glob模式字符串组成,符合路径的文件将不会触发chokidar的监听事件。

这个数组包括:

  • .git 目录下的所有内容
  • node_modules 目录下的所有内容
  • test-results 目录下的所有内容 (与Playwright有关)
  • config.cacheDir 目录下的所有内容
  • config.build.outDir目录下的所有内容(如果outDir是一个有效目录)
  • 入参options.ignored提供的额外路径

同时,将ignoreInitial设置为true,这样意味着,在chokidar启动时,它不会触发任何与初始文件状态相关的事件。

然后ignorePermissionErrors也设置为true,如果chokidar在尝试访问某个文件或目录时遇到权限错误,它将不会抛出异常并停止工作,而是会静默地忽略这些错误,并继续监视那些它有权限访问的文件和目录。

然后合并入参的options提供的额外的配置,并返回合并的配置。

createServer调用中,入参options还提供了disableGlobbingtrue的配置,这样将禁用chokidar.watchchokidar.addglob模式匹配功能。ignored不受影响。

我们注意到,最后使用了serverConfig.watch合并整个配置,这个就是Vite配置中的server.watch配置,它拥有最高的优先级。

也就是说,server.watch其实就是chokidaroptions

虽然可以自定义chokidaroptions,包括ignored选项,但有个例外------目前没有可行的方式来监听 node_modules 中的文件,具体看看这个issues

现在已经获取到了chokidaroptions。那么下一步就是创建一个watcher,来监听文件变动。

typescript 复制代码
  const watchEnabled = serverConfig.watch !== null
  const watcher = watchEnabled
    ? (chokidar.watch(
        [
          root,
          ...config.configFileDependencies,
          ...getEnvFilesForMode(config.mode, config.envDir),
        ],
        resolvedWatchOptions,
      ) as FSWatcher)
    : createNoopWatcher(resolvedWatchOptions)

首先,会检查server.watch是否被设置为null,如果是null说明不需要监听文件变动,那么就使用createNoopWatcher初始化一个空壳watcher,这个空壳watcher具有正品watcher所有对应的方法,但都是空函数。

反之,则使用chokidar.watch监听以下文件夹的变动

  • 项目根目录
  • Vite 配置文件
  • 符合当前环境的env文件。可参考整合配置项

好了,现在我们已经创建了一个功能健全的watcher。那么我们怎么用呢?

typescript 复制代码
const container = await createPluginContainer(config, moduleGraph, watcher)

我们注意到,在创建插件容器的时候,用到了watcher

本章我们不讲插件容器,但我们需要知道插件容器为什么用了watcher

我们知道插件容器是Vite用于管理和运行插件运行的一套机制。

并且Vite设计时考虑到了对Rollup插件生态系统的兼容性,许多Rollup插件可以直接在Vite中作为开发或构建插件使用。

那么插件容器肯定也实现了不少Rollup的上下文。

比如addWatchFile

针对addWatchFileRollup文档这么说:

添加额外的文件以在监视模式下监视,以便更改这些文件将触发重建。

那么在Vite中,addWatchFile这么实现的

typescript 复制代码
    addWatchFile(id: string) {
       // 略
      if (watcher) ensureWatchedFile(watcher, id, root)
    }

如果watcher存在,那么就会将watcherid(文件路径)放入ensureWatchedFile处理。

ensureWatchedFile的逻辑也很简单。它经过以下判断:

  • 这个文件路径不是一个虚拟模块
  • 这个文件路径非空且是一个有效路径
  • 这个文件不是项目目录下的------项目目录下的文件会被默认监听,没必要再次添加。

如果传入的文件路径都符合,那么就会使用watcher.add,将其添加到 watcher 中进行监视。

如果监听的文件发生变动,那么就会触发相应的事件:

typescript 复制代码
  watcher.on('change', async (file) => {
    file = normalizePath(file)
    await container.watchChange(file, { event: 'update' })
    moduleGraph.onFileChange(file)
    await onHMRUpdate(file, false)
  })

  watcher.on('add', (file) => {
    onFileAddUnlink(file, false)
  })
  watcher.on('unlink', (file) => {
    onFileAddUnlink(file, true)
  })
  • 如果文件发生变动,那么就会触发watchChange钩子------兼容RollupwatchChange。然后修改模块依赖图,最后交给热更新处理。
  • 如果是新增文件或者删除文件,那么使用onFileAddUnlink处理。

PublicFiles这一章我们讲了onFileAddUnlink针对静态资源服务的处理,除了静态资源服务,它还会触发watchChange钩子。最后修改模块依赖图,之后交给热更新处理。

那么,watcher就只做这些事情吗?

肯定不是的。

createServer中,我们注意到在这里使用了watcher

typescript 复制代码
getFsUtils(config).initWatcher?.(watcher)

这个是做什么的?

我们知道在Vite中,需要频繁解析文件路径,而这些解析方法可以整理成一个工具集fsUtils,但是在某些IO密集的场景,旧机器可能需要在解析id(文件路径)上花费大量的时间。

特别是工具集fsUtils用到fs.realpathSync.nativetryResolveRealFileWithExtensions 的时候。

这样就带来了性能问题。

那么需要结合watcher增加一个缓存策略,让fsUtilsAPI只需要解析真实地址一次,其它的时候从缓存拿就可以了。

当然真实逻辑并非直接缓存这么简单,实际上对还fsUtilsAPI进行了方法重写,比如利用 readdirwithFileTypes: true,替换之前多次调用的 fs.existsSyncfs.statSyncfs.realpathSync 等方法

要开启这个缓存策略,需要确保以下要素:

  • Vite的整合配置项中,command配置项需要是serve,这个在整合配置项中可以确定当前上下文的commandserve
  • Vite的配置中,server.fs.cachedChecks需要是true
  • Vite的配置中,并没有自定义server.watch.ignored
  • Vite的配置中,resolve.preserveSymlinks 属性为 trueVite的配置中的root为真实路径
  • 没有使用Yarn pnp

符合以上所有条件,即可开始缓存策略,使用带有缓存的fsUtils,只要不符合任一条件,就使用不带缓存的fsUtils

我们注意到,是否使用带有缓存的fsUtils,是基于整合配置项来判断的。

因此fsUtils本身也可以被缓存。

所以在getFsUtils中,Vite首先会拿着config从基于WeakMap的缓存获取fsUtils

如果获取不到才会走上面的判断,获取是否带缓存的fsUtils,然后将config作为key,缓存当前的fsUtils

所以在整合配置项不变的前提下,fsUtils也不会变。

那么带缓存的fsUtils和不带缓存的fsUtils有什么区别呢?

显而易见,两边的方法都是相同的,不过不带缓存的fsUtils使用commonFsUtils对象,而带缓存的fsUtilscreateCachedFsUtils(config)闭包创建。

也就是fsUtilsgetFsUtils创建。

虽然缓存是闭包创建,是一个局部缓存,但由于会根据config缓存fsUtils,所以针对相同config,使用的都是相同的fsUtils,缓存也是复用的。

同时带缓存的fsUtils会多暴露initWatcher方法。

typescript 复制代码
    initWatcher(watcher: FSWatcher) {
      watcher.on('add', (file) => {
        onPathAdd(file, 'file_maybe_symlink')
      })
      watcher.on('addDir', (dir) => {
        onPathAdd(dir, 'directory_maybe_symlink')
      })
      watcher.on('unlink', onPathUnlink)
      watcher.on('unlinkDir', onPathUnlink)
    }

会监听watcheraddaddDirunlinkunlinkDir事件,然后使用onPathAddonPathUnlink进行处理。

onPathAddonPathUnlink的逻辑也很简单,就是对缓存路径进行新增和删除。

这里并没有监听change事件,毕竟这里是针对文件信息的缓存,所以只关心,文件新建和删除,并不关心文件的修改。

中间件

接下来我们正式进入中间件的了解。

上篇文章我们提到过,Vite使用connect作为中间件实现方案,在express3.x及以前,同样使用connect作为中间件实现方案,虽然在4.x版本弃用connect,但依旧使用对应的理念。

都先定义先执行的递归调用。或者说是半个洋葱模型,与严格的洋葱模型还是有区别的。

corsMiddleware

corsMiddleware是第一个定义的中间件,用于实现server.cors,默认启用。

typescript 复制代码
  const { cors } = serverConfig
  if (cors !== false) {
    middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
  }

这个中间件实际上使用cors这个包,同时也是express的组件之一。因此我们完全可以使用它的配置项,直接配置server.cors

cachedTransformMiddleware

然后是cachedTransformMiddleware中间件。

typescript 复制代码
middlewares.use(cachedTransformMiddleware(server))

这个中间件是做什么用的呢?

我们先从一个HTTP请求头说起------If-None-Match

这个请求头的作用通常用于GET请求,用于检查资源的状态。

它用于告知服务器客户端所拥有的某个资源的特定版本标识,在Vite里面是通过 ETag来表示的。

当客户端发起带有If-None-Match 头的请求时,服务器会检查该值与当前资源的 ETag 是否匹配。

如果匹配,则表示客户端已经拥有了最新版本的资源,服务器可以直接返回 304 Not Modified 响应,而无需重新传输资源内容。

换句话说,如果开发服务器收的请求中,携带的ETag与要请求的文件相同,那么就可以不用返回具体文件,而是直接返回304,客户端收到后就直接用缓存。

而这个中间件就是实现ETag校验的,这样就可以使得没有发生变化的文件不用走后面的中间件,省略了不少重复逻辑,从而直接使用缓存。

那么使用Vite的时候,客户端如何发起带有If-None-Match 头的请求呢?

这里有个前提:需要用户未开启NetworkDisable cache

  • 在强制刷新页面时。
  • 在热模块重载期间,当除了修改的模块外,所有代码都进行全面重载时
  • 在第二次启动如果大型应用程序中已经预热了一半或更多模块。

我们看看它是怎么实现的

typescript 复制代码
export function cachedTransformMiddleware(
  server: ViteDevServer,
): Connect.NextHandleFunction {
  return function viteCachedTransformMiddleware(req, res, next) {
    const ifNoneMatch = req.headers['if-none-match']
    if (ifNoneMatch) {
      const moduleByEtag = server.moduleGraph.getModuleByEtag(ifNoneMatch)
      if (moduleByEtag?.transformResult?.etag === ifNoneMatch) {
        const maybeMixedEtag = isCSSRequest(req.url!)
        if (!maybeMixedEtag) {
          res.statusCode = 304
          return res.end()
        }
      }
    }
    next()
  }
}

这个中间件直接返回一个函数,没有预处理内容。

首先,它会检查请求中是否包含了 If-None-Match

如果包含,说明客户端本地已经有一份缓存了,然后会从请求头获取Etag,从模块依赖图查找符合Etag的模块。然后进行校验。

如果校验通过还要看是不是css类型的资源,因为如果css使用链接导入的同时还用模块的方式导入,它们的资源名一样,但ETag是不同的,内容也不一样,模块导入虽然后缀是.css,但实际上内容却是js

但拦截的请求都是XXXX.css,但如果ETag不同的话,还是可以根据模块依赖图中的Etag进行对比的来区分不同文件的。

但问题是,浏览器总会拿着模块导入的ETag。链接引入的css也是携带模块导入的ETag

可以围观这个pull

甚至不止是ChromeFirefoxSafari都具有相同的行为。

这样显然不对,所以为了解决这个问题,这里干脆css都不走缓存了,但是,真的完全不缓存css吗?肯定不是的,在transformMiddleware中依然会再次处理,这个我们之后会讲。

如果满足条件则返回304,否则使用next移交控制权。

proxyMiddleware

只有server.proxy有值才会启用。

在这个中间件存在初始化逻辑。

首先,它使用node-http-proxy这个包,把每个server.proxy的键值对,分别创建了一个http-proxy实例,如果配置了configure方法,还会将这个http-proxy实例和当前路径的配置项传入这个方法。

然后监听http-proxy实例的以下事件:

  • error事件,当代理发送错误的时候,会向客户端发送对应的响应。
  • proxyReqWs事件,这个事件只会记录ws的错误信息。
  • proxyRes,在代理服务器收到来自目标服务器的响应时触发,用于在客户端关闭连接时销毁响应对象。这个是node-http-proxy的一个问题,可参考这个issues

同时还会监听httpServer也就是HTTP服务器的 upgrade 事件,以处理 ws 请求。当收到一个带有 Upgrade 请求头的 HTTP 请求时(表示客户端希望升级连接至 ws),会遍历预配置好的代理上下文列表来查找匹配当前请求 URLws 代理设置。

如果找到匹配项,并且目标是 ws 服务器(根据其目标地址判断),则执行以下操作:

  • 应用任何已配置的重写规则------如果配置了rewrite
  • 输出调试信息以记录路由和转发行为。
  • 使用 proxy.ws() 方法将 ws 连接请求转发给目标服务器。

初始化结束后,会返回一个函数,这个函数就是connect中间件。

typescript 复制代码
function viteProxyMiddleware(req, res, next) {
    const url = req.url!
    for (const context in proxies) {
      if (doesProxyContextMatchUrl(context, url)) {
        const [proxy, opts] = proxies[context]
        const options: HttpProxy.ServerOptions = {}
        if (opts.bypass) {
          const bypassResult = opts.bypass(req, res, opts)
          if (typeof bypassResult === 'string') {
            req.url = bypassResult
            return next()
          } else if (bypassResult === false) {

            res.statusCode = 404
            return res.end()
          }
        }

        if (opts.rewrite) {
          req.url = opts.rewrite(req.url!)
        }
        proxy.web(req, res, options)
        return
      }
    }
    next()
  }

这个中间件该函数在接收到请求时遍历 proxies 中的所有代理实例。

当请求 URL 匹配到某个代理上下文时,会检查是否配置了bypass,这个在Vite文档里面没有,但实际上跟 webpack-dev-server起到的作用相同,这个函数是根据一个自定义逻辑来决定是否绕过特定的请求。如果函数返回false则返回404,并中断中间件执行。

如果返回值是字符串则用这个字符串覆盖req.url,然后使用next移交请求控制权。

如果配置了rewrite,就应用这个重写规则。

其他情况则使用http-proxy代理请求至目标服务器。

如果没有匹配到代理,则使用next移交控制权。

baseMiddleware

如果base不是/的时候才会启用。

这个中间件不存在初始化过程。所以我们直接看它的中间件函数。

在中间件函数执行的时候,检查请求路径 pathname 是否以给定的base开头,如果是,则将请求的 url 重写为去除base路径后的形式,并调用 next() 将控制权交给下一个中间件。

当请求路径为 //index.html 时,会拼上base并进行重定向,并中断中间件执行。

对于其他情况

  • 如果请求头中 accept 包含 text/html,则返回一个包含 HTML404 响应,提示用户可能需要访问的正确地址,并提供链接跳转至正确的基于基础路径的 URL
  • 其他情况,则返回一个文本类型的 404 响应,同样提示用户可能需要访问的正确地址。

viteHMRPingMiddleware

这个是HMR的心跳检测,代码很简单。

typescript 复制代码
  middlewares.use(function viteHMRPingMiddleware(req, res, next) {
    if (req.headers['accept'] === 'text/x-vite-ping') {
      res.writeHead(204).end()
    } else {
      next()
    }
  })

如果请求头accepttext/x-vite-ping,那么向客户端发送一个空内容的成功响应,状态码为 204(No Content)表示请求已成功处理但没有返回任何内容。并中断中间件执行。

其他情况,则使用next移交控制权。

servePublicMiddleware

上文我们讲过,只有配置了publicDir才会生效。

整个中间件基于sirv实现。

在初始化中,将sirv起始目录设置为配置中的publicDir,意味着从publicDir目录提供静态文件服务。

transformMiddleware

transformMiddleware是主要的转换中间件,它负责拦截处理各种文件的请求,并将其内容进行解析、加载、转化。同时还会处理之前没有处理的css重复ETag的问题。

这个中间件虽然也存在初始化过程,但只是获取静态资源目录以及检查它是否在项目目录中。

在中间件执行开始,会检查是否是非GET请求或者是排除在外的请求,比如favicon.ico。只要符合一项,则使用next移交控制权。

如果是.map请求,会检查是否指向预构建模块,如果是的话,提取预构建的map文件返回(这里使用了try catch,所以无论如何都会返回一个map)。如果指向的项目模块,那么就尝试从模块依赖图中获取map数据,组装成json返回,如果模块依赖图中没有对应的map数据,则使用next移交控制权。

如果是cssimportjshtml-proxy请求那么就来到了transformMiddleware的主要逻辑。

对于css,如果是链接导入,那么就重写为模块导入的地址,在cachedTransformMiddleware提到过,如果一个css既使用链接引入,也使用了模块导入的方式。

浏览器总会使用模块导入的ETag,那么就直接拿模块导入地址来查询ETag,毕竟ETag是多少无所谓,只需要关心这个css文件有无改动。

如果通过地址查询的ETag与请求的ETag相同,那么就返回304。在这里处理了css请求携带If-None-Match的情况。

其它情况,则使用transformRequest处理资源,并返回处理结果。

具体transformRequest实现与模块依赖图有关,我们之后再讲,不过我们之前提到过一些,可作为本中间件的补充。

serveRawFsMiddleware

这个模块用来处理特定请求开头的资源,同样也是基于sirv实现的。

在初始化中,将sirv起始目录设置为/,意味着从根目录提供静态文件服务。

如果请求以/@fs/开头,这表示请求的文件位于root目录之外。在这种情况下,将进行路径重写,将/@fs/后面的路径作为静态资源的代理路径。

那问题来了,既然可以根据链接读取非目录项目文件,那么岂不是存在安全隐患?只要攒一个带有/@fs/开头的链接出来,就可以访问任意目录。

实际上Vite是由ensureServingAccess来判断哪些资源可以被访问到的。

ensureServingAccess中,使用isFileServingAllowed来判断文件能否访问。

  • 如果server.fs.strictfalse,无论如何都可以访问外层文件的,这个配置项默认是true
  • 是否在拒绝名单server.fs.deny中,默认为['.env', '.env.*', '*.{crt,pem}']
  • 是否为项目中使用到的文件,由模块依赖图判断
  • 是否在server.fs.allow名单中,如果没有配置,Vite 默认将当前目录加入到 allow中,如果是 monorepo 项目,还会将 workspaces 的目录加入到 allow

如果访问的路径被isFileServingAllowed判断为false,也就是不在可访问名单中,还会检查具体路径是否存在------如果真的存在对应的路径,那么就返回403禁止访问。但是如果路径并不是真实存在的,那么就使用next移交控制权。说明当前路径不是当前中间件可以处理的。

从上面可以看出来,Vite如果遇到项目外请求没有在可访问名单,并不会盲目返回403,而是给予后续中间件一丝机会。

serveStaticMiddleware

这个中间件同serveRawFsMiddleware类似。也是管理静态资源的,不过管理是项目根目录内的静态资源。

在初始化中,将sirv起始目录设置为配置中的root,意味着从项目目录提供静态文件服务。

可能有人问,那么岂不是跟servePublicMiddleware类似吗?

确实,不过servePublicMiddleware代理的是publicDir目录下的资源,如果在index.html引入了非publicDir的静态资源,那么就会走到这个中间件中。

这个中间件首先过滤掉了/html、内部请求(/@fs//@vite-client等),这些被过滤的请求会用next传递给下一个中间件处理。

对于命中的静态资源,会解析请求的地址,同时还会根据配置的resolve.alias进行路径重写。

当然,依然通过ensureServingAccess来判断是否可访问目标文件。

indexHtmlMiddleware

我们在preTransformRequests讲过这个中间件,虽然只有spa或者mpa启用,但实际上不仅是html的转化,还承接了文件预热、模块依赖图构建的作用。

notFoundMiddleware errorMiddleware

这两个中间件作为兜底中间件,逻辑也很简单,notFoundMiddlewareindexHtmlMiddleware一样,只有spa或者mpa启用,走到notFoundMiddleware里面,会直接返回404并结束中间件递归。

errorMiddlewarenotFoundMiddleware后面,但它默认启用,也就是说在非spa且非mpa情况下,兜底中间件就从notFoundMiddleware更换为errorMiddleware

如果处于中间件模式,errorMiddleware只会执行next移交控制权,并不会中断中间件的执行,但不处于中间件模式会返回一个包含错误信息的html页面,状态码是500,中断中间件的执行。

结束

我们已经了解了chokidarVite中的重要作用,以及在每个中间件中的功能。

进一步深入研究了Vite的缓存策略、可能遇到的问题以及解决方法,以及模块依赖图的生成和其在构建过程中的作用。

在后面,我们将探讨模块依赖图的具体产生过程,以及Vite是如何进行依赖预构建的。

相关推荐
lichenyang4539 分钟前
从 Express 老项目到 NestJS + Docker:一次车辆管理系统的渐进式重构
前端
竹林8181 小时前
用 wagmi v2 + viem 监听链上事件,我踩了三天坑终于搞懂了实时日志与历史补全
javascript
Momo__1 小时前
VueUse createReusableTemplate —— 单文件组件内的模板复用神器
前端·vue.js
只一1 小时前
😭从回调地狱到 async/await:一文打通 Ajax 与 JS 异步编程
javascript
程序员小富1 小时前
我开源了一个开发者专属的智能 JSON 工具,得到了媳妇高度认可
前端·vue.js·后端
小小小小宇1 小时前
程序员如何给 LLM 装工具以及看懂推理过程
前端
写代码的皮筏艇1 小时前
React中的forwardRef
前端·react.js·面试
槑有老呆2 小时前
花三个月工资请了个 AI 程序员,结果它连青岛啤酒股价都查不了
前端
风骏时光牛马2 小时前
Verilog开发常见问题汇总解析
前端
子兮曰2 小时前
AI Coding Method Map:一张图看懂 AI 编程的完整链路
前端·人工智能·后端