本文使用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
是serve
Vite
的配置中,server.fs.cachedChecks
需要是true
。Vite
的配置中,并没有自定义server.watch.ignored
Vite
的配置中,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
作为中间件实现方案,在express
3.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
是如何进行依赖预构建的。