漫谈构建工具(十三):聊一聊 Vite 中的一个关键实例 - ViteDevServer

1,786 阅读10分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

在上一篇文章 - 我是如何利用持久化缓存策略来提升 Vite 开发模式下首屏性能的? 中,小编给大家展示了如何使用持久化缓存策略来优化 Vite 开发模式下的首屏性能,不知道小伙伴们看到之后有没有尝试一下呢?

在这个优化策略中,最关键的一步就是能获取到 moduleGraph 数据并保存到本地,而小编也通过 Vite 提供的 ViteDevServer 这个 API 顺利完成了这一步。在文章发布以后,小编又回去对 ViteDevServer 做了一下深入研究,发现这个 APIVite 中有非常重要的地位。借助这个 API,我们可以做好多事情。

今天,小编就通过本文给大家介绍一下 ViteDevServer 的基本情况以及它可以应用的场景。

本文的目录结构如下:

初识 ViteDevServer

首先,小编给大家直接展示一下 viteDevServer 这个 API 的结构:

interface ViteDevServer {
    // 解析以后的 vite config 对象
    config: ResolvedConfig;
    // 一个 connect app 实例,用于给 dev server 添加 middleware
    middlewares: Connect.Server;
    // 基于 http 模块实现的一个 http server
    httpServer: http.Server | null;
    // Chokidar watcher 实例
    watcher: FSWatcher;
    // websocket server 实例
    ws: WebSocketServer;
    // 插件容器实例,用于触发指定 plugin hook 的执行
    pluginContainer: PluginContainer;
    // 源文件对应的模块依赖图
    moduleGraph: ModuleGraph;
    // ??
    resolvedUrls: ResolvedServerUrls | null;
    // 根据请求,执行 resolve、load、transform 操作,并返回 transform 以后的内容
    transformRequest( url: string, options?: TransformOptions ): Promise<TransformResult | null>;
    // 用于对 index.html 文件做修改;
    transformIndexHtml(url: string, html: string): Promise<string>;
    // ssr 相关,暂不介绍
    ssrLoadModule( url: string, options?: { fixStacktrace?: boolean } ): Promise<Record<string, any>>;
    // ssr 相关,暂不介绍
    ssrFixStacktrace(e: Error): void;
    // 热更新时更新 moduleGraph 中的相关 module
    reloadModule(module: ModuleNode): Promise<void>;
    // 启动 server
    listen(port?: number, isRestart?: boolean): Promise<ViteDevServer>;
    // 重新启动 server
    restart(forceOptimize?: boolean): Promise<void>;
    // 关闭 server
    close(): Promise<void>
}

· 观察上面的数据结构,我们可以发现 ViteDevServer 其实就是一个普通的 js 对象,并没有要特别要注意的,关键还是要看这个对象内部的属性和方法。不过要想对这些属性有个清晰的认识,小编还是要先给大家介绍一下 Vite 开发模式下的整个工作过程。

非常简单,整个工作过程可以理解为下面这 3 个步骤:

  • 第一步,启动一个本地 server,用于响应浏览器发起的请求。

    由于浏览器发起的请求类型不同,如 htmljscss、接口等,不同类型有不同的处理逻辑,因此需要给 server 添加一些 middlewares 来分别处理这些请求。

    另外,为了实现热更新功能,还需要实现一个 watcherwebsocket server。其中,watcher 用于监听源文件是否发生编辑,websocket server 用于推送模块更新消息。

  • 第二步,浏览器发起请求,server 端根据请求 url 返回合适的 response 给浏览器。

    在这个过程中,server 端会根据请求携带的 urletag 信息,判断浏览器的缓存是否可用。

    如果可用,直接返回 304;如果不可用,解析请求 url 为真实路径,读取源文件内容,并对源文件内容做转换,然后将转换以后的内容返回给浏览器,并且缓存起来,方便下一次收到相同请求时进行处理。

  • 第三步,关闭 server,结束工作。

有了上面的铺垫,那么再理解 ViteDevServer 对象内的各个属性和方法,就非常简单了:

  • config,保存 vite.config.ts 文件提供的配置项信息;

  • httpServer, 基于 http 模块实现的 server 实例,响应浏览器发起的请求;

  • middleware, 给 httpServer 添加各种需要的中间件;

  • watcherwsreloadModule, 配合实现热更新;

  • pluginContainer, 插件容器,用于触发 plugin hook

  • transformIndexHtml, 对 html 类型的文件进行内容转换;

  • transformRequest, 将浏览器发起的请求 url,解析为源文件的真实路径,加载并转换源文件,将转换后的源文件内容返回给浏览器;

  • moduleGraph,缓存 transformRequest 返回的源文件内容;

  • listen, 启动 http server

  • close, 关闭 http serverwspluginContainerwatcher

  • restart, 重新启动 http server

  • ssrLoadModulessrFixStacktrace, ssr 相关,由于小编还未理解,暂不解释;

在使用 Vite 时,如果想在外部程序中访问到 ViteDevServer,我们只能借助 configureServer hook。这个 hook 会在 ViteDevServer 初始化成功以后触发。

const myPlugin = () => ({ 
    name: 'configure-server', 
    configureServer(server) {
        // 可以在这里使用 ViteDevServer
        ...
    }
})

上面这种方式,ViteDevServer 只能在 configureServer hook 内部使用,我们可以对插件做些稍微改造,使得 ViteDevServer 可以在其他 hook 中使用。

const myPlugin = () => {
    let _server = null;
    return () => {
        name: 'configure-server',
        configureServer: async (server) {
            _server = server;
        },
        buildStart: async () => {
            // 可以通过 _server 使用 ViteDevServer
        }
    }
};

我们可以使用 ViteDevServer 做些什么

了解了 ViteDevServer 的详细情况以后,我们再来看看使用 ViteDevServer 可以做些什么。

读/写 vite config

通过 ViteDevServer.config, 我们可以获取到 vite.config.ts 文件中的配置项信息,然后对 config 信息进行读写。

实际上,Vite 已经提供了两个 hook 来对 config 信息进行读写 - config hookconfigResolved hook。其中, config hook 用于读写解析以前的 config 信息,即 vite.config.ts 直接提供的配置项;configResolved hook 用于读写解析以后的 config 信息。

ViteDevServer.config 又给我们提供了一种读写解析以后的 config 信息的方式。

有了 configResolved hookViteDevServer.config, 我们以后就可以这样读写 config 信息:

const myPlugin = () => {
    let _server = null;
    let _config = null
    return () => {
        name: 'configure-server',
        configResolved: async (config) => {
            _config = config
        },
        configureServer: async (server) {
            _server = server;
        },
        buildStart: async () => {
            // 可以通过 _config 读写 config 信息
            // 也可以通过 _server.config 读写 config 信息
        }
    }
};

添加自定义 middleware

通过 ViteDevServer.middleware, 我们可以给 http server 添加自定义中间件, 对浏览器发起的请求做一些自定义操作。

添加自定义 middleware,有两种方式:

const myPlugin = () => {
    let _server = null;
    return () => {
        name: 'configure-server',
        configureServer: async (server) {
            server.middlewares.use((req, res, next) => { 
                // 自定义逻辑操作
                ...
                if (xx) {
                    // 后续的 middleware 不执行
                    return res.end();
                }
                // 后续的 middleware 继续执行
                next(); 
            })
        }
    }
};

const myPlugin = () => {
    let _server = null;
    return () => {
        name: 'configure-server',
        configureServer: async (server) {
            return () => {
                server.middlewares.use((req, res, next) => { 
                    // 自定义逻辑操作
                    ...
                    if (xx) {
                        // 后续的 middleware 不执行
                        return res.end();
                    }
                    // 后续的 middleware 继续执行
                    next(); 
                });
            }
        }
    }
};

这两种方式的唯一区别:

  • 第一种,自定义 middlewareVite 内部 middleware 之前触发;

  • 第二种,自定义 middlewareVite 内部 middleware 之后触发;

在实际应用中,我们可以根据自身情况,合理选择添加自定义 middleware 的方式。

监听、读取 moduleGraph 中的模块信息

通过 ViteDevServer.moduleGraph,我们可以读取和监听已经缓存的源文件信息。

首先,我们可以先来看看 moduleGraph 的源码结构。

class ModuleGraph {
  // map,根据请求 url 获取对应的源文件 module 信息
  urlToModuleMap = new Map<string, ModuleNode>();
  // map,根据 id 获取对应的源文件 module 信息
  idToModuleMap = new Map<string, ModuleNode>();
  // map, 根据源文件真实路径获取源文件对应的 module 信息
  fileToModulesMap = new Map<string, Set<ModuleNode>>();
  safeModulesPath = new Set<string>();
  ...
  // 根据请求 url 获取源文件 module 信息
  async getModuleByUrl(rawUrl: string, ssr?: boolean): Promise<ModuleNode | undefined> {
    const [url] = await this.resolveUrl(rawUrl, ssr)
    return this.urlToModuleMap.get(url)
  }
  // 根据 id 获取源文件 module 信息
  getModuleById(id: string): ModuleNode | undefined {
    return this.idToModuleMap.get(removeTimestampQuery(id))
  }
  // 根据源文件真实路径获取源文件 module 信息
  getModulesByFile(file: string): Set<ModuleNode> | undefined {
    return this.fileToModulesMap.get(file)
  }
  
  ...
  // 将请求 url 解析为源文件真是路径
  async resolveUrl(url: string, ssr?: boolean): Promise<ResolvedUrl> {
    url = removeImportQuery(removeTimestampQuery(url))
    const resolved = await this.resolveId(url, !!ssr)
    const resolvedId = resolved?.id || url
    const ext = extname(cleanUrl(resolvedId))
    const { pathname, search, hash } = parseUrl(url)
    if (ext && !pathname!.endsWith(ext)) {
      url = pathname + ext + (search || '') + (hash || '')
    }
    return [url, resolvedId, resolved?.meta]
  }
} 

在上面的代码中,小编列举了 moduleGraph 中几个关键的属性和方法, 其中 urlToModuleMap / idToModuleMap / fileToModulesMap 用来存储请求 url / id / 源文件真实路径和源文件 module 对象的映射关系,getModuleByUrl / getModuleById / getModulesByFile 用来根据请求 url / id / 源文件真实路径来获取源文件 module 对象。

其中,getModuleByUrl 比较特殊,返回一个 promise 对象,因此我们使用时要配合 async / await 一起使用。

另外,如果我们需要将请求 url 转化为源文件真实路径,可以使用 resolveUrl

接下来,我们再看看 Module 对象的源码结构:

class ModuleNode {
  url: string
  id: string | null = null
  file: string | null = null
  type: 'js' | 'css'
  info?: ModuleInfo
  meta?: Record<string, any>
  // 父 module
  importers = new Set<ModuleNode>()
  // 子 module
  importedModules = new Set<ModuleNode>()
  acceptedHmrDeps = new Set<ModuleNode>()
  acceptedHmrExports: Set<string> | null = null
  importedBindings: Map<string, Set<string>> | null = null
  isSelfAccepting?: boolean
  // 源文件转换以后的内容
  transformResult: TransformResult | null = null
  ssrTransformResult: TransformResult | null = null
  ssrModule: Record<string, any> | null = null
  ssrError: Error | null = null
  lastHMRTimestamp = 0
  lastInvalidationTimestamp = 0
  ...
}

interface TransformResult {
  code: string  // 转换以后的源文件内容字符串
  map: SourceMap | null
  etag?: string  // etag 信息,协商缓存使用
  deps?: string[] // 静态依赖
  dynamicDeps?: string[] // 动态依赖
}

通过 module.importersmodule.importedModules, 我们可以获取到当前模块依赖的父模块和子模块,并且通过 module.transformResult,可以获取到源文件转换以后的内容、etag 信息等。

有了这些信息以后,我们就可以在实际应用中利用 moduleGraph 做一些事情:

  • 读取 moduleGraph 中的相关信息,并且保存到本地,如我们上一篇文章中提到的持久化缓存插件 persistent-cache-plugin

  • 监听 moduleGraph 内容的变化,然后根据变化做一些自定义修改,如社区中的 vite-plugin-optimize-persist 插件的实现;

  • moduleGraph 中收集的源文件 moduletransformResult 进行修改;

  • ...

手动触发文件的处理

通过 ViteDevServer 提供的 transformIndexHtmltransformRequest 这两个 api,我们可以在某些场景下,手动触发对请求文件的处理。

针对这两个 api,我们可以这样使用:

  • 在一个自定义 middleware 中,如果我们想手动触发 html 文件的转换,我们可以直接调用 transformIndexHtml 这个方法,然后将转换以后的内容返回给浏览器器。

  • 某些请求,我们想禁用协商缓存策略,可以在自定义 middleware 中手动调用 transformRequest 方法,不走 Vite 原来的那一套逻辑。

  • 像上一篇 persistent-cache-plugin 中一样,手动调用 transformRequest 方法,完成对 moduleGraph 内容的填充。

  • ...

手动触发指定类型的 plugin hook

通过 ViteDevServer.pluginContainer, 我们可以手动触发指定类型的 plugin hook

我们可以先来看看 pluginContainer 的源码结构:

interface PluginContainer {
  options: InputOptions
  getModuleInfo(id: string): ModuleInfo | null
  buildStart(options: InputOptions): Promise<void>
  // 触发 resolveId 类型的 hook
  resolveId(
    id: string,
    importer?: string,
    options?: {
      custom?: CustomPluginOptions
      skip?: Set<Plugin>
      ssr?: boolean
      /**
       * @internal
       */
      scan?: boolean
      isEntry?: boolean
    }
  ): Promise<PartialResolvedId | null>
  // 触发 transform 类型的 hook
  transform(
    code: string,
    id: string,
    options?: {
      inMap?: SourceDescription['map']
      ssr?: boolean
    }
  ): Promise<SourceDescription | null>
  // 触发 load 类型的 hook
  load(
    id: string,
    options?: {
      ssr?: boolean
    }
  ): Promise<LoadResult | null>
  // 触发 buildEnd、closeBundle 类型的 hook
  close(): Promise<void>
}

在实际应用中,我们可以通过 ViteDevServer.pluginContainerresolveIdtransformloadclose 方法,手动触发对应的 plugin hook

手动关闭 dev server

通过 ViteDevServer.close 方法,我们可以手动关闭 http serverwswatcher,并触发 buildEndcloseBundle 类型的 hook

close 方法的源码如下:

    async close() {
      if (!middlewareMode) {
        process.off('SIGTERM', exitProcess)
        if (process.env.CI !== 'true') {
          process.stdin.off('end', exitProcess)
        }
      }
      await Promise.all([
        // 关闭 watcher
        watcher.close(),
        // 关闭 websocket server
        ws.close(),
        // // 触发 buildEnd、closeBundle 类型的 hook
        container.close(),
        // 关闭 http server
        closeHttpServer()
      ])
      server.resolvedUrls = null
    },

在之前的 persistent-cache-plugin 中,小编就是借助 ViteDevServer.close 方法,实现了 dev server 的手动关闭。

结束语

到这里,关于 ViteDevServer 的介绍以及使用 ViteDevServer 可以做些什么的梳理就结束了。

最后我们做一个简单的总结:

  • 通过 configureServer hook 获取 ViteDevServer
  • 借助 ViteDevServer,我们可以完成读/写 vite config、添加自定义 middleware、监听、读取 moduleGraph 中的模块信息、手动触发文件的处理、手动触发指定类型的 plugin hook、手动关闭 dev server 等;

如果小伙伴们还有其他的应用场景,欢迎在评论区留言哈,😄。