本文使用vite 5.1.0-beta.6版本
在上文中,我们了解了Vite如何创建开发服务器和整合配置项。
而本文你会学到
Vite
的开发服务器底层是基于什么Vite
的中间件底层是基于什么Vite
如何面对端口占用做了什么Vite
如何处理publicDir
目录Vite
的servePublicMiddleware
中间件做了什么
httpServer
我们了解了创建服务器后是的逻辑,也在前一节已经了解了整个createServer
的工作流程,但是具体细节并没有去推敲,比如httpServer
是如何创建出来的呢?
在把配置项整合结束后,Vite
执行了其他的逻辑,其中较为关键的,就是初始化httpServer
。
在初始化之前,需要根据config.server.https
的配置项,通过resolveHttpsConfig
进一步整合。
typescript
const httpsOptions = await resolveHttpsConfig(config.server.https)
在resolveHttpsConfig
中,会尝试读取config.server.https
中定义的ca
、cert
、key
、pfx
路径,也就是读取CA
证书、SSL
证书、私钥、公钥,将读取的内容覆盖原来的地址,如果读取失败,那么原值返回。
然后是根据整合的httpsOptions
初始化httpServer
。
typescript
import connect from 'connect'
const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)
我们注意到,这里使用connect这个包创建了中间件,这个包的作者基于这个机制,相继创造了Express
和Koa
。
然后根据是否是中间件模式,来判断httpServer
能否有值,这里我们的是非中间件模式,因此会进入resolveHttpServer
函数,初始化httpServer
。
我们看看对应的逻辑
typescript
export async function resolveHttpServer(
{ proxy }: CommonServerOptions, // 获取 server.proxy
app: Connect.Server, // middlewares
httpsOptions?: HttpsServerOptions, // 整合的httpsOptions
): Promise<HttpServer> {
if (!httpsOptions) {
//略 创建并返回 HTTP1 服务器实例
}
if (proxy) {
//略 创建并返回 HTTPS 服务器实例
} else {
// 如果不需要代理,使用 HTTP2 创建服务器
const { createSecureServer } = await import('node:http2')
return createSecureServer(
{
// 手动增加会话内存,以防止在大量请求时出现 502 ENHANCE_YOUR_CALM 错误
maxSessionMemory: 1000,
...httpsOptions, // 将传入的 httpsOptions 配置选项合并到新的配置中
allowHTTP1: true, // 允许使用 HTTP1
},
app, // middlewares
)
}
}
初始化httpServer
的逻辑实际就是使用node:http
和node:http2
初始化服务器的额逻辑。
如果没有使用过server.https
配置,那么就使用node:http
创建HTTP1
服务器。
如果使用了server.https
配置,并且使用了server.proxy
,那么就使用node:https
创建HTTPS
服务器。
否则就使用node:http2
创建HTTP2
服务器。
好了,我们现在初始化httpServer
,然后就可以直接用了吗?且慢。它还需要往几个逻辑里面露一下脸。
比如关闭逻辑。
typescript
const closeHttpServer = createServerCloseFn(httpServer)
这个closeHttpServer
变量看起来有点眼熟,我们在服务器快捷键-quit里面注意到,快捷键关闭服务器的逻辑中,会执行这个方法。
我们进入它的逻辑看看。
typescript
export function createServerCloseFn() {
// 如果httpServer为 null,则返回 resolve
// 用于跟踪是否服务器已经开始监听
let hasListened = false
// 用于存储当前打开的 sockets
const openSockets = new Set<net.Socket>()
// 当有新的连接建立时,将 socket 添加到 openSockets 中
// 当 socket 关闭时,从 openSockets 中移除
// 当服务器首次开始监听时,标记为已监听
server.once('listening', () => {
hasListened = true
})
return () =>
new Promise<void>((resolve, reject) => {
// 销毁所有仍然打开的 sockets
openSockets.forEach((s) => s.destroy())
// 如果服务器已经开始监听,调用 server.close 关闭服务器
if (hasListened) {
//略 如果关闭过程中出现错误,reject
//略 如果成功关闭,resolve
})
} else {
//略 如果服务器尚未开始监听,resolve
}
})
}
这个函数会初始化一些内部变量,比如hasListened
和openSockets
,并返回一个关闭函数,这个函数会监听一次listening
事件,并把hasListened
设置为true
,同时监听connection
和close
事件,对openSockets
进行增删。
在关闭服务器的时候,会主动卸载openSockets
中所有的socket
,如果hasListened
不是true
,那么直接执行resolve
,否则,需要在server.close
的回调函数中,执行reject
或者resolve
。这样可以保持同步执行。
然后,还会根据httpServer
的address().port
来更新端口号,因为存在端口占用的情况,所以实际端口可能并非初识值,在这里进行更新一下。
typescript
if (!middlewareMode && httpServer) {
httpServer.once('listening', () => {
serverConfig.port = httpServer.address().port
})
}
最后,还需要对listen
函数做一下封装。
typescript
const initServer = async () => {
initingServer = (async function () {
//略
}
// 如果不是中间件模式,并且存在 httpServer
if (!middlewareMode && httpServer) {
// 重写 httpServer.listen,在服务器启动之前初始化优化器
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port) => {
try {
// 确保 WebSocket 服务器已启动
hot.listen()
// 初始化服务器
await initServer()
} catch (e) {
// 如果发生错误,触发 'error' 事件并返回
httpServer.emit('error', e)
return
}
// 调用原始的 listen 方法
return listen(port, ...args)
}) as any
} else {
//略 如果启用了 options.hotListen,启动 WebSocket 服务器
// 初始化服务器
}
首先定义了初始化逻辑函数initServer
,这个函数执行插件的 buildStart
钩子、启动依赖预构建、进行文件预热等功能。
在定义完函数后,如果是中间件模式,或者 httpServer
为 null
,则直接启动 WebSocket
服务器,再执行initServer
。
否则会重写httpServer.listen
,在重写的httpServer.listen
中,会先执行 WebSocket
服务器,再执行initServer
,如果在初始化过程中发生错误,会触发 error
事件。最后再执行原来的listen
逻辑。
看到这里,有人会非常疑惑,在/vite/src/node/cli.ts
中,执行的listen
是从createServer
返回值上获取的。
typescript
// cli.ts
const server = await createServer({
//...
})
await server.listen()
而我们刚刚重写的listen
是httpServer.listen
上的,也就是说,逻辑链并不完全。
httpServer
的使命并没有结束。
我们直接看看server.listen
的逻辑。
typescript
async listen(port?: number, isRestart?: boolean) {
await startServer(server, port)
if (httpServer) {
//略
}
return server
}
果然没错,这里面有个startServer
函数,我们看看它做了什么。
typescript
async function startServer(){
const httpServer = server.httpServer // 获取httpServer
if (!httpServer) {
// 抛出错误
}
const options = server.config.server // 获取服务器配置选项
const hostname = await resolveHostname(options.host) // 解析 hostname
const configPort = inlinePort ?? options.port // inlinePort 优先级大于配置中的端口
const port = // 确定最终端口
// 启动 HTTP 服务器,并获取实际运行的端口
const serverPort = await httpServerStart(httpServer, {
port,
strictPort: options.strictPort,
host: hostname.host,
})
server._currentServerPort = serverPort // 更新当前运行的端口
}
它首先检查是否存在httpServer
,如果不存在则抛出错误,因为我们在前面已经了解到,如果在中间件模式,是不需要执行listen
,直接就会启动ws
监听和initServer
。
接下来,该函数从整合配置中获取服务器相关的选项,并解析配置中的host
名以及端口号(优先使用传入的端口)。
在非严格端口模式下,实际运行时使用的端口可能与原始配置的端口不同。
为了保证在开发过程中重启服务时浏览器标签页已经打开的URL
不会发生变化(以免影响开发者体验),函数会遵循以下逻辑来决定最终使用的端口:
- 如果当前配置的端口为空或者与之前保存的配置端口相同,将使用上次成功运行时所用的端口。
- 否则,如果配置端口有更新,则使用新的配置端口。
同时,函数内部记录了最新的配置端口值,并调用httpServerStart
。
我们多次提到了严格端口,其实指server.strictPort
这个配置,文档对这个配置是如下描述。
设为 true 时若端口已被占用则会直接退出,而不是尝试下一个可用端口。
那么我们顺藤摸瓜,看看httpServerStart
做了什么。
typescript
export async function httpServerStart(
httpServer: HttpServer,
serverOptions: {
port: number // 要监听的端口号
strictPort: boolean | undefined // 是否使用严格严格端口
host: string | undefined
logger: Logger
},
): Promise<number> {
let { port, strictPort, host, logger } = serverOptions // 获取配置选项
// 返回一个 Promise,用于处理服务器启动的异步过程
return new Promise((resolve, reject) => {
const onError = (e: Error & { code?: string }) => {
// 如果端口被占用
// 如果使用严格端口检查,直接 reject
// 否则尝试使用下一个端口
// 如果发生其他错误,reject
}
httpServer.on('error', onError)
// 开始监听指定的端口和host
httpServer.listen(port, host, () => {
// 移除错误事件监听器
httpServer.removeListener('error', onError)
// 返回实际运行的端口号
resolve(port)
})
})
}
这个函数比较简单,在内部,定义了一个onError
函数,用于监听 httpServer
的 error
事件。
当尝试监听的端口已被占用时,根据 strictPort
决定不同的行为:
- 如果
strictPort
为true
,则移除错误事件监听器,并抛出错误信息,说明指定的端口已经被使用。 - 如果
strictPort
为false
或未设置,则尝试自增端口号并在新的端口上重新监听。
对于非端口占用的其他错误,也会移除错误事件监听器并抛出原始错误。
最后,在成功监听到指定端口后,会移除错误事件监听器,并通返回实际监听成功的端口号。
我们这里注意到,终于调用了httpServer.listen
。
在这一刻,server.listen
和httpServer.listen
联系起来,调用server.listen
其实就是经过strictPort
的逻辑包装后,调用了httpServer.listen
。
startServer
的逻辑结束了,我们再看看后面的逻辑
typescript
async listen(port?: number, isRestart?: boolean) {
if (httpServer) {
server.resolvedUrls = await resolveServerUrls(
httpServer,
config.server,
config,
)
if (!isRestart && config.server.open) server.openBrowser()
}
return server
}
紧跟着是resolveServerUrls
。
typescript
export async function resolveServerUrls(
server: Server,
options: CommonServerOptions,
config: ResolvedConfig,
): Promise<ResolvedServerUrls> {
// 获取服务器的地址信息
const address = server.address()
// 检查是否为有效的地址信息
if (!isAddressInfo(address)) {
return { local: [], network: [] } // 如果地址信息无效,返回空数组
}
const local: string[] = [] // 存储local URL
const network: string[] = [] // 存储网network URL
const hostname = await resolveHostname(options.host) //解析hostname
const protocol = options.https ? 'https' : 'http' // 使用的协议(http 或 https)
const port = address.port // 服务器监听的端口
const base = // 确定base
// 如果指定了hostname且不是wildcardHosts,比如'0.0.0.0'
if (hostname.host !== undefined && !wildcardHosts.has(hostname.host)) {
// 略
if (loopbackHosts.has(hostname.host)) {
local.push(address)
} else {
network.push(address)
}
} else {
// 如果未指定hostname或为wildcardHosts,遍历网络接口并生成相应的 URL
// 略
}
// 返回包含本地和网络 URL 列表的对象
return { local, network }
}
这个函数是主要用于解析和构建启动后的服务器在本地与网络环境下的访问URL
。
首先,判断地址是否有效,无效则直接返回空的地址列表。
接着,获取服务器实际监听的端口号及配置的基础路径。
对于host
,如果指定了具体的非通配符host
,则构造并添加到相应的地址列表中(如果是 IPv6
,会使用 IPv6
的形式)。
若没有明确指定host
或host
是通配符,则遍历操作系统提供的所有网络接口信息。
筛选出IPv4
与IPv6
的信息,然后将127.0.0.1
替换为之前解析出的hostname
。添加到相应的地址列表中。
最后,返回local
和 network
组成的对象,它们就是之前提到的相应的地址列表中。
它们最后会被挂载到server.resolvedUrls
上面。
换句话说,如果server.listen
不执行,server.resolvedUrls
就是null
解析出来的resolvedUrls
有什么用呢?
在服务器快捷键-show server url中提到,这些信息会显示在控制台上。
那么就结束了吗?
当然还没有,在执行完resolveServerUrls
后,还要打开浏览器。
typescript
if (!isRestart && config.server.open) server.openBrowser()
我们注意到,打开浏览器的逻辑存在两个限制------config.server.open
为true
且isRestart
为false
。
换句话说,如果是服务器重启,即使在配置中将config.server.open
设置为true
也不会执行server.openBrowser
。
而在服务器快捷键-restart中提到,server.restart
正是调用了restartServer
,在restartServer
中执行了await server.listen(port, true)
,传入了isRestart
为true
。
而openBrowser
我们在服务器快捷键-open in browser中讲过它的逻辑,它甚至与文件预热都有关系。
至此一个httpServer
经历了:
- 再次整合配置
- 使用
node
和connect
初始化服务器 - 暴露关闭方法
- 劫持
httpServer.listen
server.listen
根据端口占用情况,是否使用不同端口执行httpServer.listen
- 解析
address
,挂载resolveServerUrls
上面我们讲完了httpServer
,那么它的作用仅限于此吗?
那肯定不是的,别忘了还使用了connect
包,也就是说用了中间件。
那么我们看看中间件都会拦截哪些资源请求,以及这些拦截都存在什么样的逻辑。
PublicFiles
我们获取到了整合的配置项,然后又被传入到了initPublicFiles
中。
typescript
const initPublicFilesPromise = initPublicFiles(config)
const publicFiles = await initPublicFilesPromise
那么我们看一下它的逻辑。
typescript
let fileNames: string[] // 存储文件名列表
fileNames = await recursiveReaddir(config.publicDir)
// 创建包含所有文件相对路径的 Set 集合
const publicFiles = new Set(
fileNames.map((fileName) => fileName.slice(config.publicDir.length)),
)
// 将配置和对应的公共文件集合存储在映射中
publicFilesMap.set(config, publicFiles)
// 返回公共文件集合
return publicFiles
这个函数使用recursiveReaddir
递归读取publicDir
及其子目录中的所有文件,以一维数组形式返回目录下的所有文件路径。
而对于返回的数组,还要再次进行处理,将这些路径去除config.publicDir
后,使用Set
去重存储。
同时还以当前的config
为key
,存在全局publicFilesMap
中。
我们来看看recursiveReaddir
做了什么,这里就不贴代码了。
首先验证给定的目录是否为一个真实存在的目录,如果不存在,则返回空数组。
接着使用 fs.promises.readdir
来获取包含类型信息(如是否为文件或目录)的 Dirent
对象列表。
如果遇到权限错误,那么返回空数组。
同时,如果读取到软连接会抛出错误,因此我们从这里可以了解到,publicDir
下的文件不能有软连接。
对于每个Dirent
对象,如果是目录,则再次调用 recursiveReaddir
进行递归;如果是文件,则收集它的路径。
最后,使用 Promise.all
等待所有递归操作完成,将所有的文件路径合并成一个一维数组。
因此,如果publicDir
中存在文件,那么publicFiles
就是这些文件剔除了publicDir
后的路径,所组成的数组。
然后,会在servePublicMiddleware
中间件中所处理。
typescript
const { publicDir } = config
if (publicDir) {
middlewares.use(servePublicMiddleware(server, publicFiles))
}
那么servePublicMiddleware
做了什么。
typescript
const dir = server.config.publicDir // 获取公共文件目录
// 使用 sirv 创建静态文件服务中间件
const serve = sirv(
dir,
sirvOptions({
getHeaders: () => server.config.server.headers, // 获取服务器配置中的头部信息
}),
)
// 略 将 URL 转换为文件路径
return (req, res, next) => {
// 略 如果不是公共文件或是导入请求或是内部请求,调用下一个中间件
// 否则,调用 sirv 中间件来服务静态文件
}
在初始化这个中间件的时候,首先会获取整合配置中的 publicDir
配置项,然后使用 sirv
库根据config.publicDir
设置静态文件服务器,并允许通过自定义选项来配置响应头部信息,这些信息来源于整合配置中的server.headers
。
sirv
是一个高性能、零依赖的 Node.js HTTP 服务器模块,专门用来服务静态资源文件。在当前上下文中,它被用来托管来自整合配置中的publicDir
目录的静态文件,并提供了如缓存控制和错误处理等优化功能。
然后定义了toFilePath
函数,它的作用是将入参中的URL
尝试转换为文件路径。
最后,返回中间件函数,这个中间件函数针对每次请求都会,做出如下检查
- 如果
publicFiles
存在,且当前请求转换的文件路径不在该Set
中,则跳过本次中间件,调用后续中间件。需要注意的是,这里检查的publicFiles
是否存在,而不是publicFiles.size
,所以如果publicDir
中文件为空的话,publicFiles
也是存在的。 - 若请求中的
query
存在import
标记或存在内部请求标记,则同样不使用sirv
处理此次请求,内部请求标记指的是@fs
等。 - 其他情况,也就是请求转化后的文件路径在
publicFiles
中并且不是import
和内部请求,调用之前实例化好的serve
(基于sirv
的静态文件服务器)来响应客户端对静态文件的请求。
那么,这就是PublicFiles
的全部吗?
当然不是,这里说的是初始化下的目录和目录文件,如果动态增删呢?
Vite
监控文件变动的chokidar
监听了add
和unlink
事件,统一由onFileAddUnlink
处理。
typescript
if (publicDir && publicFiles) {
if (file.startsWith(publicDir)) {
const path = file.slice(publicDir.length)
publicFiles[isUnlink ? "delete" : "add"](path)
}
}
这里我们看到,针对publicFiles
的增加和删除,是跟项目代码无关的,也就是说增加或者删除公共目录下的文件,是不检测项目代码有没有使用的,即使项目代码没有使用,也会修改publicFiles
。
同时,publicFiles
是引用类型,所以一旦publicFiles
因为实际文件系统的变更而得到更新,servePublicMiddleware
返回的中间件函数会确保能够正确处理响应的请求。
结束
我们这里大概了解了Vite
的开发服务器是怎么出来,以及中间件怎么出来的,并且以servePublicMiddleware
为入口,讲述了Vite
对公共目录下的文件请求做了什么样的处理。
接下来,我们将要面对更多的中间件,来看看Vite
在面对不同的资源类型请求,使用了什么样的逻辑。