本文使用vite 5.1.3版本
在上文中,我们了解了httpServer 和 中间件逻辑。
而本文你会学到
chokidar在Vite起到了什么作用Vite针对工具集使用了怎样的缓存策略Vite的中间件都做了什么Vite如何处理If-None-Match请求头- 针对同一个
css使用不同方法导入,现代浏览器普遍存在的问题,Vite如何处理的 Vite都在哪些中间件进行模块依赖图的构建Vite访问项目目录外的安全策略Vite用sirv都做了什么,都从哪些目录开始提供服务
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还提供了disableGlobbing为true的配置,这样将禁用chokidar.watch和chokidar.add的glob模式匹配功能。ignored不受影响。
我们注意到,最后使用了serverConfig.watch合并整个配置,这个就是Vite配置中的server.watch配置,它拥有最高的优先级。
也就是说,server.watch其实就是chokidar的options。
虽然可以自定义chokidar的options,包括ignored选项,但有个例外------目前没有可行的方式来监听 node_modules 中的文件,具体看看这个issues。
现在已经获取到了chokidar的options。那么下一步就是创建一个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。
针对addWatchFile,Rollup文档这么说:
添加额外的文件以在监视模式下监视,以便更改这些文件将触发重建。
那么在Vite中,addWatchFile这么实现的
typescript
addWatchFile(id: string) {
// 略
if (watcher) ensureWatchedFile(watcher, id, root)
}
如果watcher存在,那么就会将watcher和id(文件路径)放入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钩子------兼容Rollup的watchChange。然后修改模块依赖图,最后交给热更新处理。 - 如果是新增文件或者删除文件,那么使用
onFileAddUnlink处理。
在PublicFiles这一章我们讲了onFileAddUnlink针对静态资源服务的处理,除了静态资源服务,它还会触发watchChange钩子。最后修改模块依赖图,之后交给热更新处理。
那么,watcher就只做这些事情吗?
肯定不是的。
在createServer中,我们注意到在这里使用了watcher。
typescript
getFsUtils(config).initWatcher?.(watcher)
这个是做什么的?
我们知道在Vite中,需要频繁解析文件路径,而这些解析方法可以整理成一个工具集fsUtils,但是在某些IO密集的场景,旧机器可能需要在解析id(文件路径)上花费大量的时间。
特别是工具集fsUtils用到fs.realpathSync.native 和 tryResolveRealFileWithExtensions 的时候。
这样就带来了性能问题。
那么需要结合watcher增加一个缓存策略,让fsUtils的API只需要解析真实地址一次,其它的时候从缓存拿就可以了。
当然真实逻辑并非直接缓存这么简单,实际上对还
fsUtils的API进行了方法重写,比如利用readdir和withFileTypes: true,替换之前多次调用的fs.existsSync、fs.statSync、fs.realpathSync等方法
要开启这个缓存策略,需要确保以下要素:
Vite的整合配置项中,command配置项需要是serve,这个在整合配置项中可以确定当前上下文的command是serveVite的配置中,server.fs.cachedChecks需要是true。Vite的配置中,并没有自定义server.watch.ignoredVite的配置中,resolve.preserveSymlinks属性为true或Vite的配置中的root为真实路径- 没有使用
Yarn pnp
符合以上所有条件,即可开始缓存策略,使用带有缓存的fsUtils,只要不符合任一条件,就使用不带缓存的fsUtils。
我们注意到,是否使用带有缓存的fsUtils,是基于整合配置项来判断的。
因此fsUtils本身也可以被缓存。
所以在getFsUtils中,Vite首先会拿着config从基于WeakMap的缓存获取fsUtils。
如果获取不到才会走上面的判断,获取是否带缓存的fsUtils,然后将config作为key,缓存当前的fsUtils。
所以在整合配置项不变的前提下,fsUtils也不会变。
那么带缓存的fsUtils和不带缓存的fsUtils有什么区别呢?
显而易见,两边的方法都是相同的,不过不带缓存的fsUtils使用commonFsUtils对象,而带缓存的fsUtils由createCachedFsUtils(config)闭包创建。
也就是fsUtils由getFsUtils创建。
虽然缓存是闭包创建,是一个局部缓存,但由于会根据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)
}
会监听watcher的add、addDir、unlink、unlinkDir事件,然后使用onPathAdd、onPathUnlink进行处理。
onPathAdd、onPathUnlink的逻辑也很简单,就是对缓存路径进行新增和删除。
这里并没有监听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 头的请求呢?
这里有个前提:需要用户未开启
Network的Disable 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。
甚至不止是Chrome,Firefox 和 Safari都具有相同的行为。
这样显然不对,所以为了解决这个问题,这里干脆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),会遍历预配置好的代理上下文列表来查找匹配当前请求 URL 的 ws 代理设置。
如果找到匹配项,并且目标是 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,则返回一个包含HTML的404响应,提示用户可能需要访问的正确地址,并提供链接跳转至正确的基于基础路径的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()
}
})
如果请求头accept是text/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移交控制权。
如果是css、import、js、html-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.strict为false,无论如何都可以访问外层文件的,这个配置项默认是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
这两个中间件作为兜底中间件,逻辑也很简单,notFoundMiddleware同indexHtmlMiddleware一样,只有spa或者mpa启用,走到notFoundMiddleware里面,会直接返回404并结束中间件递归。
而errorMiddleware在notFoundMiddleware后面,但它默认启用,也就是说在非spa且非mpa情况下,兜底中间件就从notFoundMiddleware更换为errorMiddleware。
如果处于中间件模式,errorMiddleware只会执行next移交控制权,并不会中断中间件的执行,但不处于中间件模式会返回一个包含错误信息的html页面,状态码是500,中断中间件的执行。
结束
我们已经了解了chokidar在Vite中的重要作用,以及在每个中间件中的功能。
进一步深入研究了Vite的缓存策略、可能遇到的问题以及解决方法,以及模块依赖图的生成和其在构建过程中的作用。
在后面,我们将探讨模块依赖图的具体产生过程,以及Vite是如何进行依赖预构建的。