侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

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

lumozx
2024-03-02 / 0 评论 / 0 点赞 / 12 阅读 / 24605 字

本文使用vite 5.1.3版本

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

而本文你会学到

  • chokidarVite起到了什么作用

  • Vite针对工具集使用了怎样的缓存策略

  • Vite的中间件都做了什么

  • Vite如何处理If-None-Match请求头

  • 针对同一个css使用不同方法导入,现代浏览器普遍存在的问题,Vite如何处理的

  • Vite都在哪些中间件进行模块依赖图的构建

  • Vite访问项目目录外的安全策略

  • Vitesirv都做了什么,都从哪些目录开始提供服务

chokidar

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

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,来监听文件变动。

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。那么我们怎么用呢?

const container = await createPluginContainer(config, moduleGraph, watcher)

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

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

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

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

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

比如addWatchFile

针对addWatchFileRollup文档这么说:

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

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

    addWatchFile(id: string) {
       // 略
      if (watcher) ensureWatchedFile(watcher, id, root)
    }

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

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

  • 这个文件路径不是一个虚拟模块

  • 这个文件路径非空且是一个有效路径

  • 这个文件不是项目目录下的——项目目录下的文件会被默认监听,没必要再次添加。

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

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

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处理。

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

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

肯定不是的。

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

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方法。

	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,默认启用。

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

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

cachedTransformMiddleware

然后是cachedTransformMiddleware中间件。

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

  • 在强制刷新页面时。

  • 在热模块重载期间,当除了修改的模块外,所有代码都进行全面重载时

  • 在第二次启动如果大型应用程序中已经预热了一半或更多模块。

我们看看它是怎么实现的

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中间件。

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的心跳检测,代码很简单。

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

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

notFoundMiddleware errorMiddleware

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

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

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

结束

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

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

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

0
博主关闭了所有页面的评论