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

折腾是进步的阶梯

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

目 录CONTENT

文章目录

vite 5 源码分析(三):httpServer 和 publicFiles

lumozx
2024-03-02 / 0 评论 / 0 点赞 / 13 阅读 / 19860 字

本文使用vite 5.1.0-beta.6版本

在上文中,我们了解了Vite如何创建开发服务器和整合配置项

而本文你会学到

  • Vite的开发服务器底层是基于什么

  • Vite的中间件底层是基于什么

  • Vite如何面对端口占用做了什么

  • Vite如何处理publicDir目录

  • ViteservePublicMiddleware中间件做了什么

httpServer

我们了解了创建服务器后是的逻辑,也在前一节已经了解了整个createServer的工作流程,但是具体细节并没有去推敲,比如httpServer是如何创建出来的呢?

在把配置项整合结束后,Vite 执行了其他的逻辑,其中较为关键的,就是初始化httpServer

在初始化之前,需要根据config.server.https的配置项,通过resolveHttpsConfig进一步整合。

const httpsOptions = await resolveHttpsConfig(config.server.https)

resolveHttpsConfig中,会尝试读取config.server.https中定义的cacertkeypfx路径,也就是读取CA证书、SSL证书、私钥、公钥,将读取的内容覆盖原来的地址,如果读取失败,那么原值返回。

然后是根据整合的httpsOptions初始化httpServer

import connect from 'connect'

const middlewares = connect() as Connect.Server
const httpServer = middlewareMode
? null
: await resolveHttpServer(serverConfig, middlewares, httpsOptions)

我们注意到,这里使用connect这个包创建了中间件,这个包的作者基于这个机制,相继创造了ExpressKoa

然后根据是否是中间件模式,来判断httpServer能否有值,这里我们的是非中间件模式,因此会进入resolveHttpServer函数,初始化httpServer

我们看看对应的逻辑

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:httpnode:http2初始化服务器的额逻辑。

如果没有使用过server.https配置,那么就使用node:http创建HTTP1服务器。

如果使用了server.https配置,并且使用了server.proxy,那么就使用node:https创建HTTPS服务器。

否则就使用node:http2创建HTTP2服务器。

好了,我们现在初始化httpServer,然后就可以直接用了吗?且慢。它还需要往几个逻辑里面露一下脸。

比如关闭逻辑。

const closeHttpServer = createServerCloseFn(httpServer)

这个closeHttpServer变量看起来有点眼熟,我们在服务器快捷键-quit里面注意到,快捷键关闭服务器的逻辑中,会执行这个方法。

我们进入它的逻辑看看。

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
      }
    })
}

这个函数会初始化一些内部变量,比如hasListenedopenSockets,并返回一个关闭函数,这个函数会监听一次listening事件,并把hasListened设置为true,同时监听connectionclose事件,对openSockets进行增删。

在关闭服务器的时候,会主动卸载openSockets中所有的socket,如果hasListened不是true,那么直接执行resolve,否则,需要在server.close的回调函数中,执行reject或者resolve。这样可以保持同步执行。

然后,还会根据httpServeraddress().port来更新端口号,因为存在端口占用的情况,所以实际端口可能并非初识值,在这里进行更新一下。

if (!middlewareMode && httpServer) {
    httpServer.once('listening', () => {
      serverConfig.port = httpServer.address().port
    })
  }

最后,还需要对listen函数做一下封装。

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 钩子、启动依赖预构建、进行文件预热等功能。

在定义完函数后,如果是中间件模式,或者 httpServernull,则直接启动 WebSocket 服务器,再执行initServer

否则会重写httpServer.listen,在重写的httpServer.listen中,会先执行 WebSocket 服务器,再执行initServer,如果在初始化过程中发生错误,会触发 error 事件。最后再执行原来的listen逻辑。

看到这里,有人会非常疑惑,在/vite/src/node/cli.ts中,执行的listen是从createServer返回值上获取的。

// cli.ts
const server = await createServer({
//...
})
await server.listen()

而我们刚刚重写的listenhttpServer.listen上的,也就是说,逻辑链并不完全。

httpServer的使命并没有结束。

我们直接看看server.listen的逻辑。

async listen(port?: number, isRestart?: boolean) {
   await startServer(server, port)
   if (httpServer) {
    //略
   }
   return server
 }

果然没错,这里面有个startServer函数,我们看看它做了什么。

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做了什么。

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 函数,用于监听 httpServererror 事件。

当尝试监听的端口已被占用时,根据 strictPort 决定不同的行为:

  • 如果 strictPorttrue,则移除错误事件监听器,并抛出错误信息,说明指定的端口已经被使用。

  • 如果 strictPortfalse 或未设置,则尝试自增端口号并在新的端口上重新监听。

对于非端口占用的其他错误,也会移除错误事件监听器并抛出原始错误。

最后,在成功监听到指定端口后,会移除错误事件监听器,并通返回实际监听成功的端口号。

我们这里注意到,终于调用了httpServer.listen

在这一刻,server.listenhttpServer.listen联系起来,调用server.listen其实就是经过strictPort的逻辑包装后,调用了httpServer.listen

startServer的逻辑结束了,我们再看看后面的逻辑

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

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 的形式)。

若没有明确指定hosthost是通配符,则遍历操作系统提供的所有网络接口信息。

筛选出IPv4IPv6的信息,然后将127.0.0.1 替换为之前解析出的hostname。添加到相应的地址列表中。

最后,返回localnetwork组成的对象,它们就是之前提到的相应的地址列表中。

它们最后会被挂载到server.resolvedUrls上面。

换句话说,如果server.listen不执行,server.resolvedUrls就是null

解析出来的resolvedUrls有什么用呢?

在服务器快捷键-show server url中提到,这些信息会显示在控制台上。

那么就结束了吗?

当然还没有,在执行完resolveServerUrls后,还要打开浏览器。

if (!isRestart && config.server.open) server.openBrowser()

我们注意到,打开浏览器的逻辑存在两个限制——config.server.opentrueisRestartfalse

换句话说,如果是服务器重启,即使在配置中将config.server.open设置为true也不会执行server.openBrowser

而在服务器快捷键-restart中提到,server.restart正是调用了restartServer,在restartServer中执行了await server.listen(port, true),传入了isRestarttrue

openBrowser我们在服务器快捷键-open in browser中讲过它的逻辑,它甚至与文件预热都有关系。

至此一个httpServer经历了:

  1. 再次整合配置

  2. 使用nodeconnect初始化服务器

  3. 暴露关闭方法

  4. 劫持httpServer.listen

  5. server.listen根据端口占用情况,是否使用不同端口执行httpServer.listen

  6. 解析address,挂载resolveServerUrls

上面我们讲完了httpServer,那么它的作用仅限于此吗?

那肯定不是的,别忘了还使用了connect包,也就是说用了中间件。

那么我们看看中间件都会拦截哪些资源请求,以及这些拦截都存在什么样的逻辑。

PublicFiles

我们获取到了整合的配置项,然后又被传入到了initPublicFiles中。

const initPublicFilesPromise = initPublicFiles(config)
const publicFiles = await initPublicFilesPromise

那么我们看一下它的逻辑。

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去重存储。

同时还以当前的configkey,存在全局publicFilesMap中。

我们来看看recursiveReaddir做了什么,这里就不贴代码了。

首先验证给定的目录是否为一个真实存在的目录,如果不存在,则返回空数组。

接着使用 fs.promises.readdir来获取包含类型信息(如是否为文件或目录)的 Dirent 对象列表。

如果遇到权限错误,那么返回空数组。

同时,如果读取到软连接会抛出错误,因此我们从这里可以了解到,publicDir下的文件不能有软连接。

对于每个Dirent对象,如果是目录,则再次调用 recursiveReaddir 进行递归;如果是文件,则收集它的路径。

最后,使用 Promise.all 等待所有递归操作完成,将所有的文件路径合并成一个一维数组。

因此,如果publicDir中存在文件,那么publicFiles就是这些文件剔除了publicDir后的路径,所组成的数组。

然后,会在servePublicMiddleware中间件中所处理。

const { publicDir } = config
if (publicDir) {
  middlewares.use(servePublicMiddleware(server, publicFiles))
}

那么servePublicMiddleware做了什么。

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监听了addunlink事件,统一由onFileAddUnlink处理。

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在面对不同的资源类型请求,使用了什么样的逻辑。

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