0 Like
Vite 源码解析(4) - 开发服务篇

Vite 源码解析(4) - 开发服务篇

4 PV0 LikeVite
我们在解析完配置, 创建了插件容器之后, 要想运行一个开发环境, 并且持续的给客户端发送热更新 module, 开发服务是必不可少的, 本篇我们来讲一讲 vite 的开发 server 环境, 中间件机制, 以及如何监听文件的改动, 并通过 ws 发送给客户端的. 值得注意的是, 早期 vite 用的 koa 一把梭, 到了后面开始自己卷了个开发服务, 整体大同小异, 下面我们来逐一分析下.

resolveHttpsConfig

// https 配置, 主要是证书 const httpsOptions = await resolveHttpsConfig(   config.server.https,   config.cacheDir ); let { middlewareMode } = serverConfig; if (middlewareMode === true) {   middlewareMode = "ssr"; }

首先 vite 的开发环境是支持 https 的, 虽然绝大多数我们是用 localhost 的, 不过有些场景, 比如你依赖的三方服务, 必须使用 https, 否则没法调试, 那你就得支持 https 了. 该函数接收两个参数, 第一个 `ServerOptions` 是 node 标准库 https 的配置, 签名如下; 第二个是缓存文件的目录.

type ServerOptions = tls.SecureContextOptions &   tls.TlsOptions &   http.ServerOptions;

整个函数简单来讲就是你在配置里传了证书没, 没传就去缓存文件里找, 最后返回配置.

export async function resolveHttpsConfig(   https: boolean | HttpsServerOptions | undefined,   cacheDir: string ): Promise<HttpsServerOptions | undefined> {   if (!https) return undefined;   const httpsOption = isObject(https) ? { ...https } : {};   const { ca, cert, key, pfx } = httpsOption;   Object.assign(httpsOption, {     ca: readFileIfExists(ca),     cert: readFileIfExists(cert),     key: readFileIfExists(key),     pfx: readFileIfExists(pfx),   });   if (!httpsOption.key || !httpsOption.cert) {     httpsOption.cert = httpsOption.key = await getCertificate(cacheDir);   }   return httpsOption; }

`getCertificate` 就是用来获取缓存路径中的证书的, 如果没有, 就会调用 `createCertificate` 生成一个新的自签名证书, 这个不多说了, 毕竟不咋会, 匿了匿了. 总之 vite 就是一条龙服务, 你有就用你的, 顺带帮你验证下证书过没过期, 如果你没提供证书, 还帮你搞个自签名, 老铁们给尤雨溪双击 666.

async function getCertificate(cacheDir: string) {   const cachePath = path.join(cacheDir, "_cert.pem");   try {     const [stat, content] = await Promise.all([       fsp.stat(cachePath),       fsp.readFile(cachePath, "utf8"),     ]);     if (Date.now() - stat.ctime.valueOf() > 30 * 24 * 60 * 60 * 1000) {       throw new Error("cache is outdated.");     }     return content;   } catch {     const content = (await import("./certificate")).createCertificate();     fsp       .mkdir(cacheDir, { recursive: true })       .then(() => fsp.writeFile(cachePath, content))       .catch(() => {});     return content;   } }

resolveHttpServer

// 一个三方库,用于把中间件和 server 关联起来, 最基本的支持 req, res, next, use, handle 等等 const middlewares = connect() as Connect.Server; const httpServer = middlewareMode   ? null   : await resolveHttpServer(serverConfig, middlewares, httpsOptions);

下面是创建 http server, 如果是 http, 就直接用 `node:http``createServer`; 否则就是 https, 那么优先用 `node:http2`, 因为我们知道 http2 强制使用 https 的; 当然如果是代理, 就得用 `node:https`. 看了一下 issue 是这样解释的:

http-proxy (The underlying module which vite uses for proxy) does not support http2. You cannot use https with proxy as vite is now using Http2.

export async function resolveHttpServer(   { proxy }: CommonServerOptions,   app: Connect.Server,   httpsOptions?: HttpsServerOptions ): Promise<HttpServer> {   if (!httpsOptions) {     const { createServer } = await import("http");     return createServer(app);   }   // #484 fallback to http1 when proxy is needed.   if (proxy) {     const { createServer } = await import("https");     return createServer(httpsOptions, app);   } else {     const { createSecureServer } = await import("http2");     return createSecureServer(       {         // Manually increase the session memory to prevent 502 ENHANCE_YOUR_CALM         // errors on large numbers of requests         maxSessionMemory: 1000,         ...httpsOptions,         allowHTTP1: true,       },       // @ts-expect-error TODO: is this correct?       app     ) as unknown as HttpServer;   } }

createWebSocketServer

const ws = createWebSocketServer(httpServer, config, httpsOptions);

当我们改动源码时, chokidar 会监听到文件变化, 然后交给 rollup 去做编译, 当编译完成后就要通知到前端进行热更新, 那么我们就需要一个 websocket 的服务, vite 使用了 ws 这个库, 而 `createWebSocketServer` 基本就是 ws 的封装. 当然要补充一个小知识, 就是 ws 的开启需要用 http 做为引导, http 返回 101 状态码后方可升级成 ws.

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade

整个函数因为不复杂, 我们直接在源码上写注释.

export function createWebSocketServer(   server: Server | null,   config: ResolvedConfig,   httpsOptions?: HttpsServerOptions ): WebSocketServer {   let wss: WebSocketServerRaw;   let httpsServer: Server | undefined = undefined;   const hmr = isObject(config.server.hmr) && config.server.hmr;   // 用户可以自行提供一个 server, 不过一般不会这么玩   const hmrServer = hmr && hmr.server;   const hmrPort = hmr && hmr.port;   // TODO: the main server port may not have been chosen yet as it may use the next available   const portsAreCompatible = !hmrPort || hmrPort === config.server.port;   const wsServer = hmrServer || (portsAreCompatible && server);   // 监听器, key 是事件名, value 是 WebSocketCustomListener 格式的集合   const customListeners = new Map<string, Set<WebSocketCustomListener<any>>>();   const clientsMap = new WeakMap<WebSocketRaw, WebSocketClient>();   if (wsServer) {     wss = new WebSocketServerRaw({ noServer: true });     // 如果用户自行提供了 http server, 那么监听到 101 状态码, 就升级成 ws     wsServer.on("upgrade", (req, socket, head) => {       // 如果 sec-websocket-protocol 为 vite-hmr, 也就是 vite 的热更新协议, 那么就接受       // 这块代码写的很严谨, 为了防止恰好命中了其他 http 过来的 101       if (req.headers["sec-websocket-protocol"] === HMR_HEADER) {         // 升级完协议后就正式连接到 ws 了         wss.handleUpgrade(req, socket as Socket, head, (ws) => {           wss.emit("connection", ws, req);         });       }     });   } else {     const websocketServerOptions: ServerOptions = {};     const port = hmrPort || 24678;     const host = (hmr && hmr.host) || undefined;     if (httpsOptions) {       // if we're serving the middlewares over https, the ws library doesn't support automatically creating an https server, so we need to do it ourselves       // create an inline https server and mount the websocket server to it       // 因为 ws 没提供 https 的 server, 需要自己起一个       httpsServer = createHttpsServer(httpsOptions, (req, res) => {         // 426 Upgrade Required 表示服务器拒绝处理客户端使用当前协议发送的请求, 但是可以接受其使用升级后的协议发送的请求         // 不过 STATUS_CODES[statusCode] 这个操作没搞懂, 肯定不是 undefiend 啊? 我给提 pr 那哥们留了个 comment, 先留个 TODO:         const statusCode = 426;         const body = STATUS_CODES[statusCode];         if (!body)           throw new Error(             `No body text found for the ${statusCode} status code`           );         res.writeHead(statusCode, {           "Content-Length": body.length,           "Content-Type": "text/plain",         });         res.end(body);       });       httpsServer.listen(port, host);       websocketServerOptions.server = httpsServer;     } else {       // we don't need to serve over https, just let ws handle its own server       websocketServerOptions.port = port;       if (host) {         websocketServerOptions.host = host;       }     }     // vite dev server in middleware mode     // 最终我们跑起了一个 WebSocket 的服务, 默认端口是 24678     wss = new WebSocketServerRaw(websocketServerOptions);   }   // 接下来就是对 ws 的封装了   wss.on("connection", (socket) => {     // message 事件是 ws 接收到数据后触发的事件     socket.on("message", (raw) => {       // 如果没有事件监听器, 收到消息也处理不了, 直接 return       if (!customListeners.size) return;       // 拿到解析后的数据, 校验合法性       // parsed 就是当源码改了, chokidar 通过 rollup 改完之后, 发送给 ws 的数据       let parsed: any;       try {         parsed = JSON.parse(String(raw));       } catch {}       if (!parsed || parsed.type !== "custom" || !parsed.event) return;       // 根据 event 名去事件监听器集合中查找相应的监听器集合       const listeners = customListeners.get(parsed.event);       // 如果监听器集合没有, 直接 return       if (!listeners?.size) return;       // getSocketClient 这个函数下面有解释, 就是给 client 封装一层, 保证发送数据的一致性       const client = getSocketClient(socket);       // 遍历监听器集合, 逐一执行       listeners.forEach((listener) => listener(parsed.data, client));     });     socket.send(JSON.stringify({ type: "connected" }));     // 如果编译出错了啥的, 也把错误暴露给前端     if (bufferedError) {       socket.send(JSON.stringify(bufferedError));       bufferedError = null;     }   });   // 监听失败事件   wss.on("error", (e: Error & { code: string }) => {     if (e.code === "EADDRINUSE") {       config.logger.error(         colors.red(`WebSocket server error: Port is already in use`),         { error: e }       );     } else {       config.logger.error(         colors.red(`WebSocket server error:\n${e.stack || e.message}`),         { error: e }       );     }   });   // Provide a wrapper to the ws client so we can send messages in JSON format   // To be consistent with server.ws.send   // 对 ws 客户端进行封装, 保证发送一致性的 json 格式的数据   // 也就是说, 对于每个新进来的 ws 实例, 都对它的 send 方法(发送给客户端的 payload)进行了封装, 使其发送的数据格式一致   function getSocketClient(socket: WebSocketRaw) {     if (!clientsMap.has(socket)) {       clientsMap.set(socket, {         send: (...args) => {           let payload: HMRPayload;           if (typeof args[0] === "string") {             payload = {               type: "custom",               event: args[0],               data: args[1],             };           } else {             payload = args[0];           }           socket.send(JSON.stringify(payload));         },         socket,       });     }     return clientsMap.get(socket)!;   }   // On page reloads, if a file fails to compile and returns 500, the server   // sends the error payload before the client connection is established.   // If we have no open clients, buffer the error and send it to the next   // connected client.   let bufferedError: ErrorPayload | null = null;   // 下面大家就太熟悉了, 一个典型 EventEmitter   // 尤雨溪确实好这口, vue 源码里我记得也有个类似的东西, 我们就不多说了   return {     on: ((event: string, fn: () => void) => {       if (wsServerEvents.includes(event)) wss.on(event, fn);       else {         if (!customListeners.has(event)) {           customListeners.set(event, new Set());         }         customListeners.get(event)!.add(fn);       }     }) as WebSocketServer["on"],     off: ((event: string, fn: () => void) => {       if (wsServerEvents.includes(event)) {         wss.off(event, fn);       } else {         customListeners.get(event)?.delete(fn);       }     }) as WebSocketServer["off"],     get clients() {       return new Set(Array.from(wss.clients).map(getSocketClient));     },     send(...args: any[]) {       let payload: HMRPayload;       if (typeof args[0] === "string") {         payload = {           type: "custom",           event: args[0],           data: args[1],         };       } else {         payload = args[0];       }       if (payload.type === "error" && !wss.clients.size) {         bufferedError = payload;         return;       }       const stringified = JSON.stringify(payload);       wss.clients.forEach((client) => {         // readyState 1 means the connection is open         if (client.readyState === 1) {           client.send(stringified);         }       });     },     close() {       return new Promise((resolve, reject) => {         wss.clients.forEach((client) => {           client.terminate();         });         wss.close((err) => {           if (err) {             reject(err);           } else {             if (httpsServer) {               httpsServer.close((err) => {                 if (err) {                   reject(err);                 } else {                   resolve();                 }               });             } else {               resolve();             }           }         });       });     },   }; }

chokidar

在 server 搭建好后, 就需要使用 chokidar 监听文件变化, 来触发 hmr.

const { ignored = [], ...watchOptions } = serverConfig.watch || {}; // chokidar 实例 const watcher = chokidar.watch(path.resolve(root), {   ignored: [     "**/node_modules/**",     "**/.git/**",     ...(Array.isArray(ignored) ? ignored : [ignored]),   ],   ignoreInitial: true,   ignorePermissionErrors: true,   disableGlobbing: true,   ...watchOptions, }) as FSWatcher; // 监听文件变化 watcher.on("change", async (file) => {   file = normalizePath(file);   // 如果是 package.json 的变化, 需要更新 packageCache 的数据   if (file.endsWith("/package.json")) {     return invalidatePackageData(packageCache, file);   }   // invalidate module graph cache on file change   // 当有源码文件变化, 重塑模块依赖图   moduleGraph.onFileChange(file);   if (serverConfig.hmr !== false) {     try {       // 并进行 hmr, 这个我们下一章主讲       await handleHMRUpdate(file, server);     } catch (err) {       ws.send({         type: "error",         err: prepareError(err),       });     }   } });

server 实例

至此我们就跑起来一个服务, 下面是它最终的实例, 这里面的大部分方法都跟 hmr 有关, 我们只是简单贴一下, 下一章重点来讲 hmr. 至于 ssr, 由于官方还不稳定, 等稳定了后面再填坑.

const server: ViteDevServer = {   config, // 配置   middlewares, // 中间件   httpServer, // http 服务   watcher, // chokidar   pluginContainer: container, // 插件容器   ws, // ws 模块   moduleGraph, // 模块依赖图   ssrTransform(code: string, inMap: SourceMap | null, url: string) {     return ssrTransform(code, inMap, url, {       json: { stringify: server.config.json?.stringify },     });   },   transformRequest(url, options) {     // 当有 bundle 发生变化时, 转换成请求     return transformRequest(url, server, options);   },   transformIndexHtml: null!, // to be immediately set   async ssrLoadModule(url, opts?: { fixStacktrace?: boolean }) {     await updateCjsSsrExternals(server);     return ssrLoadModule(       url,       server,       undefined,       undefined,       opts?.fixStacktrace     );   },   ssrFixStacktrace(e) {     if (e.stack) {       const stacktrace = ssrRewriteStacktrace(e.stack, moduleGraph);       rebindErrorStacktrace(e, stacktrace);     }   },   ssrRewriteStacktrace(stack: string) {     return ssrRewriteStacktrace(stack, moduleGraph);   },   listen(port?: number, isRestart?: boolean) {     return startServer(server, port, isRestart);   },   async close() {     if (!middlewareMode) {       process.off("SIGTERM", exitProcess);       if (process.env.CI !== "true") {         process.stdin.off("end", exitProcess);       }     }     await Promise.all([       watcher.close(),       ws.close(),       container.close(),       closeHttpServer(),     ]);   },   printUrls() {     if (httpServer) {       printCommonServerUrls(httpServer, config.server, config);     } else {       throw new Error("cannot print server URLs in middleware mode.");     }   },   async restart(forceOptimize?: boolean) {     if (!server._restartPromise) {       server._forceOptimizeOnRestart = !!forceOptimize;       server._restartPromise = restartServer(server).finally(() => {         server._restartPromise = null;         server._forceOptimizeOnRestart = false;       });     }     return server._restartPromise;   },   _ssrExternals: null,   _restartPromise: null,   _importGlobMap: new Map(),   _forceOptimizeOnRestart: false,   _pendingRequests: new Map(), };

configureServer

由于 vite 的插件系统提供了 `configureServer` 钩子, configureServer 钩子将在内部中间件被安装前调用, 所以自定义的中间件将会默认会比内部中间件早运行. 如果你想注入一个在内部中间件之后运行的中间件, 你可以从 configureServer 返回一个函数, 将会在内部中间件安装后被调用.

// apply server configuration hooks from plugins const postHooks: ((() => void) | void)[] = []; for (const plugin of config.plugins) {   if (plugin.configureServer) {     postHooks.push(await plugin.configureServer(server));   } }

middleware 总览

解析来 vite 将执行一票内置中间件, 我们先看个概览, 下面逐一学习.

// Internal middlewares ------------------------------------------------------ // request timer // 在 debug 模式统计一次请求耗费的时间 if (process.env.DEBUG) {   middlewares.use(timeMiddleware(root)); } // cors (enabled by default) // 处理跨域 const { cors } = serverConfig; if (cors !== false) {   middlewares.use(corsMiddleware(typeof cors === "boolean" ? {} : cors)); } // proxy // 处理代理 const { proxy } = serverConfig; if (proxy) {   middlewares.use(proxyMiddleware(httpServer, proxy, config)); } // base // base 是开发或生产环境服务的公共基础路径, 默认是  '/', 如果用户设置了其他路径, 需要藉此进行调整 if (config.base !== "/") {   middlewares.use(baseMiddleware(server)); } // open in editor support // 这个库是 vite 直接从 react-dev-utils 里拿的, 用于前端框架在报错的时候, 会在浏览器出个弹窗, 展示错误堆栈(行数, 列数, 路径) // 你点击错误, 这个中间件帮助你打开编辑器, 并定位到那一行 // (其实挺鸡肋的) middlewares.use("/__open-in-editor", launchEditorMiddleware()); // serve static files under /public // this applies before the transform middleware so that these files are served // as-is without transforms. // 保护 public 文件夹下的文件不被编译到 if (config.publicDir) {   middlewares.use(     servePublicMiddleware(config.publicDir, config.server.headers)   ); } // main transform middleware // 转换(src 下的)源码 / 资源 middlewares.use(transformMiddleware(server)); // serve static files middlewares.use(serveRawFsMiddleware(server)); middlewares.use(serveStaticMiddleware(root, server)); const isMiddlewareMode = middlewareMode && middlewareMode !== "html"; // spa fallback if (config.spa && !isMiddlewareMode) {   middlewares.use(spaFallbackMiddleware(root)); } // run post config hooks // This is applied before the html middleware so that user middleware can // serve custom content instead of index.html. // 执行后置自定义 server 钩子 postHooks.forEach((fn) => fn && fn()); if (config.spa && !isMiddlewareMode) {   // transform index.html   middlewares.use(indexHtmlMiddleware(server)); } if (!isMiddlewareMode) {   // handle 404s   // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`   // 简单地把状态码设置为 404   middlewares.use(function vite404Middleware(_, res) {     res.statusCode = 404;     res.end();   }); } // error handler // 返回一个状态码为 500 的错误 html 页面 middlewares.use(errorMiddleware(server, !!middlewareMode));

关于前几个中间件, `timeMiddleware`, `corsMiddleware`, `proxyMiddleware`, `baseMiddleware`, `launchEditorMiddleware`, 以及 `vite404Middleware`, `errorMiddleware` 就不多说, 很常见也很通用, 直接看注释即可. 我们主要来分析下面两组:

  • 跟 index.html 有关的: `transformMiddleware`,`spaFallbackMiddleware`, `indexHtmlMiddleware`
  • 跟静态文件有关的: `servePublicMiddleware`, `serveRawFsMiddleware`, `serveStaticMiddleware`

跟 index.html 相关的中间件

spaFallbackMiddleware

这个中间件的重点是 connect-history-api-fallback 这个库. 我们知道对于单页应用, 路由实际都是假的, 因此你刷新一下页面, 就到 404 了, 而这个库的目的就是在刷新后, 重写路由到 index.html 上, 这样就不会导致资源丢失了.

import fs from "fs"; import path from "path"; import history from "connect-history-api-fallback"; import type { Connect } from "types/connect"; import { createDebugger } from "../../utils"; export function spaFallbackMiddleware(   root: string ): Connect.NextHandleFunction {   const historySpaFallbackMiddleware = history({     logger: createDebugger("vite:spa-fallback"),     // support /dir/ without explicit index.html     rewrites: [       {         from: /\/$/,         to({ parsedUrl }: any) {           const rewritten =             decodeURIComponent(parsedUrl.pathname) + "index.html";           if (fs.existsSync(path.join(root, rewritten))) {             return rewritten;           } else {             return `/index.html`;           }         },       },     ],   });   // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`   return function viteSpaFallbackMiddleware(req, res, next) {     return historySpaFallbackMiddleware(req, res, next);   }; }

顺便提一嘴, 在最终线上, 你也要在 nginx 配置好路由重写, 否则也会 404.

try_files {path} /index.html

indexHtmlMiddleware

在讲这个中间件之前, 我们先看看在 public 文件夹下初始化的 index.html 文件是这样婶的:

<!DOCTYPE html> <html lang="en">   <head>     <meta charset="UTF-8" />     <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>Vite App</title>   </head>   <body>     <div id="root"></div>     <script type="module" src="/src/main.tsx"></script>   </body> </html>

但是我们在浏览器审查元素, 它变成了如下的样子:

<!DOCTYPE html> <html lang="en">   <head>     <script type="module" src="/@vite/client"></script>     <script type="module">       import RefreshRuntime from "/@react-refresh";       RefreshRuntime.injectIntoGlobalHook(window);       window.$RefreshReg$ = () => {};       window.$RefreshSig$ = () => (type) => type;       window.__vite_plugin_react_preamble_installed__ = true;     </script>     <meta charset="UTF-8" />     <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>Vite App</title>   </head>   <body>     <div id="root"></div>     <script type="module" src="/src/main.tsx"></script>   </body> </html>

之所以增加了两个 script 标签, 就是由 `indexHtmlMiddleware` 做到的, 它通过拦截 index.html, 然后根据传入的 hooks(plugin), 将 index.html 进行一番改造, 比如注入 `vite/client`, `react-fast-refresh` 等等. 上面我们在讲 spaFallbackMiddleware 时知道, 所有前端路由都被处理成 `/index.html`. 因此这个中间件首先找到 index.html 的绝对路径, 然后读取它. 再通过 `transformIndexHtml` 将其转换后, 通过 `send` 发送给前端.

export function indexHtmlMiddleware(   server: ViteDevServer ): Connect.NextHandleFunction {   // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`   return async function viteIndexHtmlMiddleware(req, res, next) {     if (res.writableEnded) {       return next();     }     const url = req.url && cleanUrl(req.url);     // spa-fallback always redirects to /index.html     // 所有前端路由都被处理成 `/index.html`     // 此外, 这里很严谨地判断了 sec-fetch-dest, 这是一个由浏览器发起的请求头, 这个请求头明确告知客户端需要什么类型的文件     // 由于是 sec(security) 开头, 客户端是无法篡改的, 详情可以看 https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode     if (url?.endsWith(".html") && req.headers["sec-fetch-dest"] !== "script") {       const filename = getHtmlFilename(url, server);       if (fs.existsSync(filename)) {         try {           // 获取 index.html 的内容           let html = fs.readFileSync(filename, "utf-8");           // 转换 html           html = await server.transformIndexHtml(url, html, req.originalUrl);           return send(req, res, html, "html", {             headers: server.config.server.headers,           });         } catch (e) {           return next(e);         }       }     }     next();   }; }

回想 `createServer` 函数, 有一段这样的代码:

server.transformIndexHtml = createDevHtmlTransformFn(server);

可见, 重点就是 `createDevHtmlTransformFn` 个函数了, 它首先用 `resolveHtmlTransforms` 函数, 根据 enforce 拿到前置和后置 hooks. 然后通过 `applyHtmlTransforms` 方法处理 index.html 的代码. 下面我们细说这两个函数.

export function createDevHtmlTransformFn(   server: ViteDevServer ): (url: string, html: string, originalUrl: string) => Promise<string> {   const [preHooks, postHooks] = resolveHtmlTransforms(server.config.plugins);   return (url: string, html: string, originalUrl: string): Promise<string> => {     return applyHtmlTransforms(html, [...preHooks, devHtmlHook, ...postHooks], {       path: url,       filename: getHtmlFilename(url, server), // 绝对路径       server,       originalUrl,     });   }; }

下面列出了 `plugin.transformIndexHtml` 的函数签名. 可见它要么是个函数, 要么是个对象: 如果是函数的话就放在 postHooks 中; 如果是个对象, 会根据 `enforce` 属性决定它应该放在 preHooks 还是 postHooks 中.

export type IndexHtmlTransform =   | IndexHtmlTransformHook   | {       enforce?: "pre" | "post";       transform: IndexHtmlTransformHook;     };
export function resolveHtmlTransforms(   plugins: readonly Plugin[] ): [IndexHtmlTransformHook[], IndexHtmlTransformHook[]] {   const preHooks: IndexHtmlTransformHook[] = [];   const postHooks: IndexHtmlTransformHook[] = [];   for (const plugin of plugins) {     const hook = plugin.transformIndexHtml;     if (hook) {       if (typeof hook === "function") {         postHooks.push(hook);       } else if (hook.enforce === "pre") {         preHooks.push(hook.transform);       } else {         postHooks.push(hook.transform);       }     }   }   return [preHooks, postHooks]; }

比如是个 react 项目, 它会引入 `@vitejs/plugin-react-refresh` 这个 plugin, 然后经过一系列操作, 在 index.html 注入以下代码, 因为这个 plugin 有个 `preambleCode` 字符串, 这个字符串正是 `@react-refresh` 的代码.

<head>   ...   <script type="module" src="/@vite/client"></script>   <script type="module">     import RefreshRuntime from "/@react-refresh";     RefreshRuntime.injectIntoGlobalHook(window);     window.$RefreshReg$ = () => {};     window.$RefreshSig$ = () => (type) => type;     window.__vite_plugin_react_preamble_installed__ = true;   </script>   ... </head>

接下来我们看一下 `applyHtmlTransforms`, 它根据上面已经排序好的 hooks 依次执行, 它返回的是 `IndexHtmlTransformResult`. 我们先看一下相关 hook 的签名.

export type IndexHtmlTransformHook = (   html: string,   ctx: IndexHtmlTransformContext ) => IndexHtmlTransformResult | void | Promise<IndexHtmlTransformResult | void>; export type IndexHtmlTransformResult =   | string   | HtmlTagDescriptor[]   | {       html: string;       tags: HtmlTagDescriptor[];     }; export interface HtmlTagDescriptor {   tag: string;   attrs?: Record<string, string | boolean | undefined>;   children?: string | HtmlTagDescriptor[];   /**    * default: 'head-prepend'    */   injectTo?: "head" | "body" | "head-prepend" | "body-prepend"; }

`IndexHtmlTransformResult` 有可能是字符串, 数组或者对象:

  • 如果是字符串, 那么直接赋值给最终 html 字符串
  • 如果是数组, 那就先赋值给 tags 变量, 等待处理
  • 如果是对象, 那就把 html 属性赋值给 html 变量, tags 属性赋值给 tags 变量, 等待处理 接下来就要对 tags 进行遍历, 根据 tag 的不同的类型, 也就是 `"head" | "body" | "head-prepend" | "body-prepend"`, 先放到不同的数组中, 然后通过 `injectToHead``injectToBody` 这两个函数, 将新增的标签及其内容添加到 html 中.
export async function applyHtmlTransforms(   html: string,   hooks: IndexHtmlTransformHook[],   ctx: IndexHtmlTransformContext ): Promise<string> {   for (const hook of hooks) {     const res = await hook(html, ctx);     if (!res) {       continue;     }     if (typeof res === "string") {       html = res;     } else {       let tags: HtmlTagDescriptor[];       if (Array.isArray(res)) {         tags = res;       } else {         html = res.html || html;         tags = res.tags;       }       const headTags: HtmlTagDescriptor[] = [];       const headPrependTags: HtmlTagDescriptor[] = [];       const bodyTags: HtmlTagDescriptor[] = [];       const bodyPrependTags: HtmlTagDescriptor[] = [];       for (const tag of tags) {         if (tag.injectTo === "body") {           bodyTags.push(tag);         } else if (tag.injectTo === "body-prepend") {           bodyPrependTags.push(tag);         } else if (tag.injectTo === "head") {           headTags.push(tag);         } else {           headPrependTags.push(tag);         }       }       html = injectToHead(html, headPrependTags, true);       html = injectToHead(html, headTags);       html = injectToBody(html, bodyPrependTags, true);       html = injectToBody(html, bodyTags);     }   }   return html; }

关于 `injectToHead``injectToBody` 我们就不多讲了, 大抵就是把下面这个对象, 变成 `<script src="/@vite/client" type='module'></script>`, 然后插入到指定位置.

const o = {   tag: "script",   attrs: {     type: "module",     src: "/@vite/client",   },   injectTo: "head-prepend", };

transformMiddleware

`transformMiddleware` 比较复杂, 大体来讲就是当浏览器成功加载了 index.html 之后, 肯定涉及一些源码的拉取; 此外, 用户交互也可能会涉及(比如切换了懒加载路由, 新页面肯定要下载对应的资源). vite 会通过原生 ESM 的方式请求源码文件, 但由于源码肯定不能被浏览器直接使用的(比如 tsx), 那这个中间件的目的就是拦截这些请求, 将这个被请求文件通过 esbuild 编译成浏览器支持的文件; 并会为该文件创建模块对象, 设置模块之间的依赖关系等等. 此外, 它还充分利用 http 的缓存机制, 来保证未过期文件的重复利用, 以提高速度. 关于 http 缓存可以看我的文章 [HTTP 系列] 第 3 篇 —— HTTP 缓存那些事.

export function transformMiddleware(   server: ViteDevServer ): Connect.NextHandleFunction {   const {     config: { root, logger },     moduleGraph,   } = server;   // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`   return async function viteTransformMiddleware(req, res, next) {     // 拉取数据肯定是 GET 请求, 如果不是直接放掉     // 此外, 如果资源是 ['/', '/favicon.ico'], 也不要通过这里处理, 因为 '/' 就是 index.html, 它已经被上面 indexHtmlMiddleware 处理了     // favicon.ico 不用多说, 给浏览器用的, 没必要处理     if (req.method !== "GET" || knownIgnoreList.has(req.url!)) {       return next();     }     let url: string;     try {       // 我们知道 vite 为了比较资源的新鲜度, 会给资源的 url 附上一个时间戳 query       // removeTimestampQuery 这个函数就是把 &t=xxxxxxxxxxxxx 干掉       // 此外, 我们知道 rollup 的插件机制有虚拟模块的概念, 按照约定如果你用了虚拟模块, 为了防止它被其他插件处理, 需要加上 \0       // 而一个合法 url 是不能存在 \0 的, 因此 vite 把它转成了 __x00__       // 所以在 decodeURI 的时候需要把它还原成 \0       // 一个现实的例子是 /@id/__x00__react/jsx-dev-runtime'       url = decodeURI(removeTimestampQuery(req.url!)).replace(         NULL_BYTE_PLACEHOLDER,         "\0"       );     } catch (e) {       return next(e);     }     // 干掉 url 的 query 和 hash     const withoutQuery = cleanUrl(url);     try {       const isSourceMap = withoutQuery.endsWith(".map");       // since we generate source map references, handle those requests here       // 首先要分析 sourcemap       if (isSourceMap) {         if (getDepsOptimizer(server.config)?.isOptimizedDepUrl(url)) {           // If the browser is requesting a source map for an optimized dep, it           // means that the dependency has already been pre-bundled and loaded           const mapFile = url.startsWith(FS_PREFIX)             ? fsPathFromId(url)             : normalizePath(                 ensureVolumeInPath(path.resolve(root, url.slice(1)))               );           try {             const map = await fs.readFile(mapFile, "utf-8");             return send(req, res, map, "json", {               headers: server.config.server.headers,             });           } catch (e) {             // Outdated source map request for optimized deps, this isn't an error             // but part of the normal flow when re-optimizing after missing deps             // Send back an empty source map so the browser doesn't issue warnings             const dummySourceMap = {               version: 3,               file: mapFile.replace(/\.map$/, ""),               sources: [],               sourcesContent: [],               names: [],               mappings: ";;;;;;;;;",             };             return send(req, res, JSON.stringify(dummySourceMap), "json", {               cacheControl: "no-cache",               headers: server.config.server.headers,             });           }         } else {           const originalUrl = url.replace(/\.map($|\?)/, "$1");           const map = (await moduleGraph.getModuleByUrl(originalUrl, false))             ?.transformResult?.map;           if (map) {             return send(req, res, JSON.stringify(map), "json", {               headers: server.config.server.headers,             });           } else {             return next();           }         }       }       // check if public dir is inside root dir       // 检查 /public 是否在 / 之内       // 老实巴交写代码的话, 实在没想到有什么反面例子...       const publicDir = normalizePath(server.config.publicDir);       const rootDir = normalizePath(server.config.root);       if (publicDir.startsWith(rootDir)) {         const publicPath = `${publicDir.slice(rootDir.length)}/`;         // warn explicit public paths         if (url.startsWith(publicPath)) {           let warning: string;           if (isImportRequest(url)) {             const rawUrl = removeImportQuery(url);             warning =               "Assets in public cannot be imported from JavaScript.\n" +               `Instead of ${colors.cyan(                 rawUrl               )}, put the file in the src directory, and use ${colors.cyan(                 rawUrl.replace(publicPath, "/src/")               )} instead.`;           } else {             warning =               `files in the public directory are served at the root path.\n` +               `Instead of ${colors.cyan(url)}, use ${colors.cyan(                 url.replace(publicPath, "/")               )}.`;           }           logger.warn(colors.yellow(warning));         }       }       // 接下来最重要的, 就是拦截客户端请求了哪些类型的资源       if (         isJSRequest(url) || // 判断是否请求 js 文件, /\.((j|t)sx?|mjs|vue|marko|svelte|astro)($|\?)/         isImportRequest(url) || // url 上挂有 import 参数的, vite 会对热更新时请求的文件等挂上 import 参数, /(\?|&)import=?(?:&|$)/         isCSSRequest(url) || // 判断是否请求 css 文件, `\\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\\?)`         isHTMLProxy(url) // url 上挂有 html-proxy 参数的,  /(\?|&)html-proxy\b/       ) {         // strip ?import, 干掉 import 参数         url = removeImportQuery(url);         // Strip valid id prefix. This is prepended to resolved Ids that are         // not valid browser import specifiers by the importAnalysis plugin.         // 如果 url 以 /@id/ 开头,则去掉 /@id/, 上面说了, 这个前缀跟 rollup 插件规范有关         url = unwrapId(url);         // for CSS, we need to differentiate between normal CSS requests and         // imports         if (           isCSSRequest(url) &&           !isDirectRequest(url) &&           req.headers.accept?.includes("text/css")         ) {           url = injectQuery(url, "direct");         }         // check if we can return 304 early         // 找出有没有与 if-none-match 匹配的 etag, 如果有直接返回协商缓存         const ifNoneMatch = req.headers["if-none-match"];         if (           ifNoneMatch &&           (await moduleGraph.getModuleByUrl(url, false))?.transformResult             ?.etag === ifNoneMatch         ) {           isDebug && debugCache(`[304] ${prettifyUrl(url, root)}`);           res.statusCode = 304;           return res.end();         }         // resolve, load and transform using the plugin container         // 对于没有命中协商缓存的, 那就需要进行一波代码编译了         // 这个我们下面详细说         const result = await transformRequest(url, server, {           html: req.headers.accept?.includes("text/html"),         });         if (result) {           const type = isDirectCSSRequest(url) ? "css" : "js";           // 我们知道 vite 会给 node_moudles 的三方依赖进行预构建           // 前端在请求三方依赖资源时, 会加上 t={browserHash},           // 或者资源有 depsCacheDirPrefix 前缀, 就认为是依赖           const isDep =             DEP_VERSION_RE.test(url) ||             getDepsOptimizer(server.config)?.isOptimizedDepUrl(url);           return send(req, res, result.code, type, {             etag: result.etag,             // allow browser to cache npm deps!             // 如果是依赖的话直接强缓存写死, 下次再请求到这个三方依赖, 直接强缓存返回!             cacheControl: isDep ? "max-age=31536000,immutable" : "no-cache",             headers: server.config.server.headers,             map: result.map,           });         }       }     } catch (e) {       if (e?.code === ERR_OPTIMIZE_DEPS_PROCESSING_ERROR) {         // Skip if response has already been sent         if (!res.writableEnded) {           res.statusCode = 504; // status code request timeout           res.end();         }         // This timeout is unexpected         logger.error(e.message);         return;       }       if (e?.code === ERR_OUTDATED_OPTIMIZED_DEP) {         // Skip if response has already been sent         if (!res.writableEnded) {           res.statusCode = 504; // status code request timeout           res.end();         }         // We don't need to log an error in this case, the request         // is outdated because new dependencies were discovered and         // the new pre-bundle dependendencies have changed.         // A full-page reload has been issued, and these old requests         // can't be properly fullfilled. This isn't an unexpected         // error but a normal part of the missing deps discovery flow         return;       }       return next(e);     }     next();   }; }

transformRequest

export function transformRequest(   url: string,   server: ViteDevServer,   options: TransformOptions = {} ): Promise<TransformResult | null> {   const cacheKey = (options.ssr ? "ssr:" : options.html ? "html:" : "") + url;   // This module may get invalidated while we are processing it. For example   // when a full page reload is needed after the re-processing of pre-bundled   // dependencies when a missing dep is discovered. We save the current time   // to compare it to the last invalidation performed to know if we should   // cache the result of the transformation or we should discard it as stale.   //   // A module can be invalidated due to:   // 1. A full reload because of pre-bundling newly discovered deps   // 2. A full reload after a config change   // 3. The file that generated the module changed   // 4. Invalidation for a virtual module   //   // For 1 and 2, a new request for this module will be issued after   // the invalidation as part of the browser reloading the page. For 3 and 4   // there may not be a new request right away because of HMR handling.   // In all cases, the next time this module is requested, it should be   // re-processed.   //   // We save the timestamp when we start processing and compare it with the   // last time this module is invalidated   const timestamp = Date.now();   const pending = server._pendingRequests.get(cacheKey);   if (pending) {     return server.moduleGraph       .getModuleByUrl(removeTimestampQuery(url), options.ssr)       .then((module) => {         if (!module || pending.timestamp > module.lastInvalidationTimestamp) {           // The pending request is still valid, we can safely reuse its result           return pending.request;         } else {           // Request 1 for module A     (pending.timestamp)           // Invalidate module A        (module.lastInvalidationTimestamp)           // Request 2 for module A     (timestamp)           // First request has been invalidated, abort it to clear the cache,           // then perform a new doTransform.           pending.abort();           return transformRequest(url, server, options);         }       });   }   const request = doTransform(url, server, options, timestamp);   // Avoid clearing the cache of future requests if aborted   let cleared = false;   const clearCache = () => {     if (!cleared) {       server._pendingRequests.delete(cacheKey);       cleared = true;     }   };   // Cache the request and clear it once processing is done   server._pendingRequests.set(cacheKey, {     request,     timestamp,     abort: clearCache,   });   request.then(clearCache, clearCache);   return request; }

跟静态文件相关的中间件

Vite 源码解析(3) - 插件篇

PREVIOUS POST

Vite 源码解析(3) - 插件篇

Vite 源码解析(5) - importAnalysis 插件

NEXT POST

Vite 源码解析(5) - importAnalysis 插件

    Search by