Vite 是如何工作的

Vite 是一个基于原生 ES 模块的一种新型前端构建工具,它通过依赖预构建、按请求对源码进行按需编译和缓存等方式使得自身在开发启动和热更新时的速度都相当可观。

在官方介绍的文档中贴有 2 张对比图,可以很好的看出 Webpaack 与 Vite 在对资源进行打包时采用的不同处理方式:

前者在启动开发服务前就会从入口文件开始,根据依赖关系将所有路由下的文件进行打包;而后者则是先启动一个开发服务,仅在每次请求时对当次请求相关的模块进行处理。

所以,显而易见的 Vite 在响应上会快不少,那么其内部具体又是如何工作的?

开发模式概览

以常见的开发模式为例,当我们执行 Vite 命令时,通过一些简单预处理之后就会像上面所说的一样,先创建一个 HTTP 服务器:

javascript 复制代码
// packages/vite/src/node/cli.ts
const server = await createServer({
  // ...
});

await server.listen();

createServer() 函数中仅仅是调用并返回了内部 _createServer() 函数的结果,创建服务器的核心流程也尽在后者之中,下面列举了一些其中比较核心的步骤:

  1. 通过 resolveConfig() 函数处理用户传递和配置和默认配置
  2. 通过 connect 模块创建一个 HTTP 服务
  3. 调用 createWebSocketServer() 函数创建一个 WebSocket 服务
  4. 使用 chokidar 监听文件变化
  5. 创建 Vite server(一个包含了一些属性和方法的对象,也是函数最后的返回值)
  6. 添加 HTML 处理器
  7. 为监听器添加 change 事件的处理器,同时基于 WebSocket 服务完成 HMR
  8. 调用 configureServer hook
  9. 根据配置添加一些内置的中间件(middlewares)
  10. run post config hooks(执行的钩子是上面执行 configureServer 钩子的返回值)

可以简单的看一下下面被高度裁剪之后的代码,了解一下创建服务器的大致流程:

javascript 复制代码
import connect from 'connect'; // Connect 是一个 Node.js 的中间件框架,Express 和 Koa 都是基于 Connect 核心的 Web 框架

export async function _createServer(
  inlineConfig: InlineConfig = {},
  options: { ws: boolean }
): Promise<ViteDevServer> {
  const config = await resolveConfig(inlineConfig, 'serve'); // 解析配置
  // ...
  const middlewares = connect();
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(serverConfig, middlewares, httpsOptions); // 创建 HTTP 服务器
  const ws = createWebSocketServer(httpServer, config, httpsOptions); // 创建 WebSocket 服务
  // ...
  // 创建 Vite server
  const server: ViteDevServer = {
    config,
    middlewares,
    httpServer,
    ws,
    // Will be overwritten to ensure initialization of the optimizer before server startup
    async listen(port?: number, isRestart?: boolean) {},
    // ...
  };
  // ...
  server.transformIndexHtml = createDevHtmlTransformFn(server); // HTML 处理器
  // ...
  return server;
}

后续,当请求来临时,就会针对请求的资源依次交由中间件进行处理。

简版 Vite

下面我们试着来实现一个简单版的 vite,以更加清晰地了解一下其内部的工作方式。

准备工作

首先创建一个新的项目,并在根目录下初始化一个 package.json 文件,其中的 bin 字段中声明执行 myvite 命令时将要运行的脚本:

json 复制代码
{
  "name": "vite",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "vite": "./bin/vite.js"
  }
}

接着就可以通过 pnpm link 命令使得当前本地包可在系统范围内或其他位置访问:

bash 复制代码
pnpm link --global

现在当我们执行 myvite 命令时就会自动运行 ./bin/vite.js 文件中的脚本了,目前该文件还不存在,所有我们先来创建它并在其中加点东西:

javascript 复制代码
#!/usr/bin/env node

function start() {
  return import('../src/node/cli.js');
}

start();

本身 Vite 在预处理一些事情之后会调用打包产生的 dist 下的文件,不过这里我们简单调用下源码目录下的文件就好了。

在 cli.js 文件中会完成一些命令注册和解析然后再根据对应命令和参数执行不同的命令,因为我们现在讨论的是开发环境的核心逻辑,所以我们直接默认执行启动开发服务的命令来创建服务器:

javascript 复制代码
(async () => {
  // 省略了命令行参数的解析,直接创建开发服务器
  const { createServer } = await import('./server/index.js');

  const server = await createServer();

  await server.listen();
})();

对应的 server/index.js 文件中会导出一个服务器的创建函数:

javascript 复制代码
export function createServer() {}

准备工作完成了,接下来开始读取配置、创建服务器吧。

解析配置

createServer() 函数中,首先会调用 resolveConfig() 函数来结合当前的开发模式、默认配置和用户传递的参数等信息来解析得到一个最终的配置对象:

javascript 复制代码
import { resolveConfig } from '../config.js';

export async function createServer(inlineConfig) {
  const config = await resolveConfig(inlineConfig);
  // ...
}

整个过程也相对比较繁琐,下面也罗列了一些其中的核心步骤:

  1. 读取配置文件中的配置项并与命令行中传递的配置进行合并
  2. 对插件进行排序,然后依次调用 config 钩子
  3. 创建 logger
  4. 加载 .env 文件
  5. 创建缓存目录
  6. 处理 worker 相关的插件,并依次调用 config 钩子
  7. 和内置插件进行整合
  8. 处理完成后依次调用普通插件和 worker 插件的 configResolved 钩子,最后返回处理的结果

不过,在我们的实现中则简陋了很多(后面遇到需要的配置再按需补充),这里仅导出了 root 和静态资源的目录地址:

javascript 复制代码
// src/node/config.js
import path from 'node:path';

export async function resolveConfig(config = {}) {
  const root = config.root ? path.resolve(config.root) : process.cwd();
  const publicDir = config.publicDir ? path.resolve(config.publicDir) : root;

  const resolved = {
    root,
    publicDir,
  };

  return resolved;
}

拿到配置后,下一步就是创建核心的 HTTP 服务了

HTTP 服务

从 Vite2 起,内部开始采用 connect 模块来创建 HTTP 服务器,要使用 connect 模块记得先使用 pnpm 进行安装。

Connect 作为 express 和 koa 的先驱同样支持灵活的中间件机制,只需要将其返回值传递的 HTTP 服务器就可以很方便的创建一个 Web 服务器:

javascript 复制代码
import connect from 'connect';
import { resolveHttpServer } from '../http.js';

export async function createServer(inlineConfig) {
  // ...
  const middlewares = connect();
  const httpServer = await resolveHttpServer(middlewares);
  // ...
}

然后在对应的 http.js 中创建并返回了一个 HTTP 服务:

javascript 复制代码
// src/node/http.js
export async function resolveHttpServer(app) {
  const { createServer } = await import('node:http');

  return createServer(app);
}

为了让其正常工作我们还需要添加一些基础的中间件。

静态服务中间件

Web 服务需要提供一个基础的静态文件中间件,用来响应静态资源的请求,sirv 是一个非常强大、灵活的静态文件服务器,这里我们使用 sirv 来提供静态服务:

javascript 复制代码
// src/node/server/middlewares/static.js
import sirv from 'sirv';

export function servePublicMiddleware(dir) {
  const serve = sirv(dir, {
    dev: true,
    etag: true,
    extensions: [],
  });

  return serve;
}

当然要让其生效也需要通过 pnpm 先进行安装,然后将它添加到中间件列表中:

javascript 复制代码
import { servePublicMiddleware } from './middlewares/static.js';

export async function createServer(inlineConfig) {
  // ...
  middlewares.use(servePublicMiddleware(config.publicDir));

  return {
    listen(port = 3000) {
      httpServer.listen(port, async () => {
        console.log(`Server listening on http://localhost:${port}`);
      });
    },
  };
}

最后再在工作目录下创建一个作为入口的 index.html 文件:

目前,我们不支持省略 index.html 访问方式,在 Vite 中基于 connect-history-api-fallback 模块创建了 htmlFallbackMiddleware 中间件来完成这项工作。

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Vite</title>
  </head>
  <body>
    <div id="app">Hello, My Vite!</div>
  </body>
</html>

现在让我们通过执行 myvite 命令启动服务,再次访问 http://localhost:3000/index.html 就能正确地访问到 HTML 中的内容了。

在 Vite 中,HTML 文件实际上并不由静态资源中间件处理,而是一个名为 indexHtmlMiddleware 的中间件,核心流程是将用户传递的插件(包含了 transformIndexHtml 钩子的)和内置的钩子合并在一起一次对原始的 html 内容进行处理;如其中内置的一个 htmlEnvHook 主要用于替换 HTML 中的环境变量。最后再返回处理后的 HTML 内容。

依赖预构建

前面我们提到 Vite 之所以快的其中一个原因就是它采用了依赖预构建的模式,在我们启动服务器之前默认的就会完成这项工作。

由于预构建的过程相对也比较繁琐,所以下面提供一张函数调用关系图,可以参考着理解:

接下来,我们可以简单的包装一下之前的 httpServer 的 listen 函数,在监听端口之前完成依赖构建:

javascript 复制代码
import { initDepsOptimizer } from '../optimizer/index.js';

export async function createServer(inlineConfig) {
  // ...
  const server = {
    async listen(port = 3000) {
      httpServer.listen(port, async () => {
        console.log(`Server listening on http://localhost:${port}`);
      });
    },
  };
  const listen = httpServer.listen.bind(httpServer);

  httpServer.listen = async function (...args) {
    await initDepsOptimizer(config, server); // 初始化优化器
    listen(...args); // 在依赖完成预构建之后再开启端口监听
  };

  return server;
}

在初始化优化器的函数中会先通过 createDepsOptimizer() 方法创建一个优化器,内部通过 discoverProjectDependencies() 方法来寻找依赖,依赖扫描又主要由其中的 scanImports() 函数完成:

javascript 复制代码
// src/node/optimizer/optimizer.js
import { scanImports } from './scan.js';

const depsOptimizerMap = new WeakMap();

export async function initDepsOptimizer(config, server) {
  await createDepsOptimizer(config, server); // 创建优化器
}

async function createDepsOptimizer(config, server) {
  const discover = discoverProjectDependencies(config); // 寻找依赖

  const deps = await discover.result;

  console.log('deps: ', deps);
}

export function discoverProjectDependencies(config) {
  const { result } = scanImports(config); // 扫描依赖项

  return {
    result: result.then(({ deps }) => deps),
  };
}

扫描时会计算所有的入口文件,默认情况下,Vite 会抓取 index.html 来检测需要预构建的依赖项,并忽略一些如 node_modules 等目录,当然也支持配置。

这里我们简单地固定为 index.html 为入口,并调用 prepareEsbuildScanner() 函数来做一些准备工作:

javascript 复制代码
// src/node/optimizer/scan.js
export function scanImports(config) {
  const deps = {}; // 用来存储扫描结果
  const entries = [path.resolve(config.root, 'index.html')]; // 默认抓取 index.html 来检测需要预构建的依赖项
  // 调用准备函数
  const result = prepareEsbuildScanner(config, entries, deps).then(
    (esbuildContext) => {
      // 准备就绪后,就主动调用构建函数
      return esbuildContext.rebuild().then(() => ({
        deps,
      }));
    }
  );

  return { result };
}

在准备函数中并涉及到了 Vite 之所以快的另一个原因:采用了使用 Go 语言编写的 JavaScript 打包工具-esbuild模块。

此处创建了一个对应的 esbuild 插件和其它参数一起创建了一个 esbuild 的构建上下文,以应对接下来的构建操作:

javascript 复制代码
async function prepareEsbuildScanner(config, entries, deps) {
  const plugin = esbuildScanPlugin(config); // 创建 esbuild 插件

  return await esbuild.context({
    absWorkingDir: process.cwd(),
    write: false,
    stdin: {
      contents: entries.map((e) => `import ${JSON.stringify(e)}`).join('\n'),
      loader: 'js',
    },
    bundle: true,
    format: 'esm',
    logLevel: 'silent',
    plugins: [plugin],
  });
}

通过 esbuild 的插件 API 可以将代码注入到构建过程的各个部分,此处主要涉及到的就是 onResolve 和 onLoad 回调。

在创建 esbuild 插件的函数中,针对 HTML 文件我们需要读取并解析 HTML 内容,提取其中的脚本引用地址,并以 JavaScript 文件内容的形式返回,作为真正的打包入口:

javascript 复制代码
function esbuildScanPlugin(config) {
  return {
    name: 'vite:dep-scan',
    setup(build) {
      // 定义 esbuild 执行路径解析的方式
      build.onResolve({ filter: /\.html$/ }, async ({ path, importer }) => {
        return {
          path,
          namespace: 'html',
        };
      });
      // 返回模块的内容,并告诉 esbuild 如何解释它
      build.onLoad(
        { filter: /\.html$/, namespace: 'html' },
        async ({ path }) => {
          const html = await fsp.readFile(path, 'utf-8');
          const [, scriptSrc] = html.match(/src="(.+)"/);

          return {
            loader: 'js',
            contents: `import ${JSON.stringify(scriptSrc)}`, // 返回 js 文件内容,内容及时引入我们在 html 中引用的脚本地址
          };
        }
      );
    },
  };
}

同时,对于 JavaScript 等其它文件我们可以则可以提供另外一对插件来处理:

javascript 复制代码
function esbuildScanPlugin(config) {
  return {
    name: 'vite:dep-scan',
    setup(build) {
      // ...
      // 处理其它类型的文件
      build.onResolve({ filter: /.*/ }, async ({ path: id }) => {
        return {
          path: path.join(config.root, id),
        };
      });
      // 处理 js 文件
      build.onLoad({ filter: /\.js$/ }, async ({ path: id }) => {
        const ext = path.extname(id).slice(1);
        const contents = await fsp.readFile(id, 'utf-8');

        return {
          loader: ext,
          contents,
        };
      });
    },
  };
}

调用 Vite 插件

现在我们在扫描函数中调用的构建函数终于可以运行了,但是并没有收集到任何依赖,因为我们并没有对上面用来存储依赖的 deps 对象做任何操作。

事实上,在上面添加的 esbuild 插件时,当前还省略了另一项重要的工作,也就是创建插件容器,可以把它理解为一个运行插件的工具。它会依次调用配置的插件上的 resolveId() 方法,如果其中任何一个插件返回了处理后的值就作为最终处理结果进行返回:

javascript 复制代码
// src/node/server/pluginContainer.js
export async function createPluginContainer(config) {
  const { plugins } = config; // 内部包含了一些默认的插件
  const container = {
    async resolveId(rawId, importer = join(root, 'index.html')) {
      for (const plugin of plugins) {
        if (!plugin.resolveId) continue;

        const result = await plugin.resolveId(rawId, importer);

        if (result) return result;

        return { id: rawId };
      }
    },
  };

  return container;
}

那么插件容器具体是在什么时候创建的呢?它会在上面开始做准备工作的地方一起被创建,同时传递给创建 esbuild 插件的函数:

javascript 复制代码
// src/node/optimizer/scan.js
import { createPluginContainer } from '../server/pluginContainer.js';

async function prepareEsbuildScanner(config, entries, deps) {
  const container = await createPluginContainer(config);
  const plugin = esbuildScanPlugin(config, container, deps);
  // ...
}

随后,在处理路径时,我们就可以调用 Vite 的插件系统进行处理,并将依赖记录到 deps 对下中:

javascript 复制代码
function esbuildScanPlugin(config, container, deps) {
  const resolve = async (id, importer, options) => {
    // 调用插件容器处理
    const resolved = await container.resolveId(id, importer, {
      ...options,
      scan: true,
    });
    const res = resolved?.id;

    return res;
  };

  return {
    name: 'vite:dep-scan',
    setup(build) {
      build.onResolve({ filter: /\.html$/ }, async ({ path, importer }) => {
        const resolved = await resolve(path, importer); // 这里

        return {
          path: resolved,
          namespace: 'html',
        };
      });
      // 处理其它类型的文件
      build.onResolve({ filter: /.*/ }, async ({ path: id, importer }) => {
        const resolved = await resolve(id, importer); // 还有这里

        if (resolved.includes('node_modules')) {
          deps[id] = resolved; // 记录依赖

          return {
            path: resolved,
            external: true,
          };
        }

        return {
          path: resolved,
        };
      });
    },
  };
}

插件的调用逻辑和记录依赖的时机已经有了,那么上面遍历的插件列表是在什么时候提供的呢?

resolvePlugin 插件

还记得最开始提到的配置解析函数吗?它会默认添加一些插件,resolvePlugin 就是其中之一:

javascript 复制代码
// src/node/plugins/resolve.js
export function resolvePlugin({ root, asSrc }) {
  return {
    name: 'vite:resolve',
    async resolveId(id, importer, resolveOpts) {
      // URL
      // /foo -> /fs-root/foo
      if (asSrc && id[0] === '/' && !id.startsWith(root)) {
        const fsPath = path.resolve(root, id.slice(1));

        return { id: fsPath };
      }
      if (isWindows && id.startsWith('/')) {
        return {
          id: path.resolve(root, id.slice(1)),
        };
      }
      if (path.isAbsolute(id)) {
        return { id };
      }
      if (id.startsWith('.')) {
        const basedir = importer ? path.dirname(importer) : process.cwd();
        const fsPath = path.resolve(basedir, id);

        return {
          id: fsPath,
        };
      }

      const resolved = tryNodeResolve(id, importer, { root });

      if (resolved) return resolved;
    },
  };
}

export function tryNodeResolve(id, importer, { root }) {
  const pkgPath = path.join(root, 'node_modules', id, 'package.json');
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
  const entry = path.join(path.dirname(pkgPath), pkg.module || pkg.main);

  return { id: entry };
}

如代码所示,其中为针对相对路径、绝对路径以及一些第三方包的加载进行一些处理,最后得到一个资源的引用地址。

开始构建

在拿到依赖项之后就可以为真正的构建做准备了,首先是在 config 的解析中获取指定的缓存目录,这里简单写死为 node_modules/.vite 目录:

javascript 复制代码
// src/node/config.js
export async function resolveConfig(config = {}) {
  // ...
  const resolved = {
    cacheDir: path.join(root, 'node_modules/.vite'),
    // ...
  };
  // ....
}

回到我们创建优化器的地方,将上面发现的依赖进行格式化后添加到 metadata 对象中,同时将已有的优化项和刚刚发现的依赖项进行合并,最后传递给 runOptimizeDeps() 函数进行处理:

javascript 复制代码
// src/node/optimizer/optimizer.js
async function createDepsOptimizer(config, server) {
  const metadata = {
    optimized: {}, // 已经优化的,首次为空
    discovered: {}, // 刚刚发现的
  };
  // 0、创建并记录优化器
  const depsOptimizer = {
    metadata,
    getOptimizedDepId: (depInfo) => depInfo.file,
  };
  depsOptimizerMap.set(config, depsOptimizer);

  depsOptimizer.scanProcessing = new Promise((resolve) => {
    (async () => {
      const discover = discoverProjectDependencies(config); // 1、开始扫描
      const deps = await discover.result;

      for (const id of Object.keys(deps)) {
        if (!metadata.discovered[id]) {
          addMissingDep(id, deps[id]); // 2、格式化刚发现的依赖项
        }
      }

      // 3、合并依赖(本应该将已优化的和刚发现的依赖信息进行合并,但我们这里简单的只处理首次的情况)
      const knownDeps = { ...metadata.discovered[dep] };
      const optimizationResult = runOptimizeDeps(config, knownDeps); // 4、 开始优化

      resolve();
      depsOptimizer.scanProcessing = undefined;
    })();
  });
}

我们将扫描依赖和构建的过程放到了一个 Promise 中,并存储在优化器的 scanProcessing 属性上;这很有用,因为我们在请求到来时需要借此来判断预构建过程是否已经完成。

接下来并是开始真正创建构建内容的时候了,首先通过配置获取到缓存路径,并在期内写入一个 package.json 文件:

javascript 复制代码
// src/node/optimizer/index.js
export function runOptimizeDeps(resolvedConfig, depsInfo) {
  const metadata = {
    optimized: {},
    discovered: {},
  };
  const { cacheDir } = resolvedConfig;
  const depsCacheDir = path.resolve(cacheDir, 'deps');

  fs.mkdirSync(depsCacheDir, { recursive: true }); // 实际上vite会先创建一个临时目录,创建完之后进行重命名
  fs.writeFileSync(
    path.resolve(depsCacheDir, 'package.json'),
    `{\n  "type": "module"\n}\n`
  );
}

紧接着和收集依赖的方式类似,会通过 prepareEsbuildOptimizerRun() 函数创建一个 esbuild 上下文来为所有的依赖预打包做准备:

javascript 复制代码
async function prepareEsbuildOptimizerRun(
  resolvedConfig,
  depsInfo,
  processingCacheDir
) {
  const flatIdDeps = {};

  Object.keys(depsInfo).map((id) => {
    const src = depsInfo[id].src;
    const flatId = flattenId(id);
    flatIdDeps[flatId] = src;
  });

  const context = await esbuild.context({
    absWorkingDir: process.cwd(),
    entryPoints: Object.keys(flatIdDeps), // 将所有的依赖作为入口
    outdir: processingCacheDir, // 直接打包到我们的缓存目录中
    bundle: true,
    format: 'esm',
    splitting: true,
    sourcemap: true,
  });

  return { context };
}

我们会在写入包文件之后就直接调用上面这个函数来完成准备工作,并在准备完成之后进行构建:

javascript 复制代码
export function runOptimizeDeps(resolvedConfig, depsInfo) {
  // ...
  const preparedRun = prepareEsbuildOptimizerRun(
    resolvedConfig,
    depsInfo,
    depsCacheDir
  );
  const runResult = preparedRun.then(({ context, idToExports }) => {
    return context.rebuild().then((result) => {
      metadata.optimized = { ...depsInfo };

      return {
        metadata,
        commit: async () => {
          const metadataPath = path.join(depsCacheDir, '_metadata.json');

          fs.writeFileSync(
            metadataPath,
            stringifyDepsOptimizerMetadata(metadata, depsCacheDir)
          );
        },
      };
    });
  });

  return { result: runResult };
}

现在,我们在测试文件中引入 Vue 后,再次执行执行 myvite 命令时就可以在缓存目录中看到预打包生成的内容了。

引入路径修改

现在我们已经完成了 HTTP 服务的创建和依赖预的预构建,但是怎么能够在用户请求时将构建的内容利用起来呢?如果现在我们在测试的入口文件中导入 Vue 的话,将会得到下面的错误:

bash 复制代码
Uncaught TypeError: Failed to resolve module specifier "vue". Relative references must start with either "/", "./", or "../".

正如错误信息所提示的,ES Module 支持加载远程模块,但是资源地址必须是绝对路径或者相对路径,对于 Node 模块这样的加载方式则并不支持。

transformMiddleware 中间件

为此我们需要将这样的模块路径进行替换,以便能够准确地找到目标文件,显然这应该发生在请求到来时,所以我们添加一个 transformMiddleware 中间件来胜任这份工作:

javascript 复制代码
export async function createServer(inlineConfig) {
  // ...
  middlewares.use(transformMiddleware(server)); // 放置在静态资源中间件之前
  middlewares.use(servePublicMiddleware(config.publicDir));
  // ...
}

在这里,也提供一张中间件执行顺序的图示:

在该中间件中主要针对 get 请求进行处理,如果匹配到了 JavaScript 文件,那么就会调用 transformRequest() 函数对内容进行处理,并将对应处理的结果返回:

javascript 复制代码
// src/node/server/middlewares/transform.js
export function transformMiddleware(server) {
  return async function viteTransformMiddleware(req, res, next) {
    if (req.method !== 'GET') {
      return next();
    }

    let url = req.url;

    if (/\.js/.test(url)) {
      const result = await transformRequest(url, server); // 处理请求

      if (result) {
        res.setHeader('Content-Type', 'application/javascript');
        res.statusCode = 200;

        return res.end(content); // 返回处理结果
      }
    } else {
      return next();
    }
  };
}

transformRequest() 函数中的逻辑主要分为解析资源地址、读取资源内容然后进行转换三个步骤:

javascript 复制代码
// src/node/server/transformRequest.js
import fsp from 'node:fs/promises';

export async function transformRequest(url, server) {
  const { pluginContainer } = server;
  const { id } = await pluginContainer.resolveId(url); // 1、解析资源地址
  const loadResult = await pluginContainer.load(id); // 2、读取资源内容
  let code;

  if (loadResult) {
    code = loadResult.code;
  } else {
    code = await fsp.readFile(id, 'utf-8');
  }

  const transformResult = await pluginContainer.transform(code, id); // 3、转换

  return transformResult;
}

可以看到核心逻辑都在插件容器中,那么这里的插件容器从何而来,又与依赖预构建处的插件容器有何关系呢?

插件容器

此处的插件容器其实也是通过前面依赖预构建部分提到的 pluginContainer 创建的,只不过更丰富些:添加了一个插件上下文以及 load 和 transform 两个钩子,整体的调用方式也是极为相似的:

javascript 复制代码
// src/node/server/pluginContainer.js
import path from 'node:path';

export async function createPluginContainer(config) {
  const { plugins } = config;

  class PluginContext {
    async resolve(id, importer = path.join(config.root, 'index.html')) {
      let out = await container.resolveId(id, importer);

      if (typeof out === 'string') out = { id: out };

      return out;
    }
  }

  const container = {
    async resolveId( // 解析路径
      rawId,
      importer = path.join(config.root, 'index.html'),
      options
    ) {
      const ctx = new PluginContext();
      const scan = !!options?.scan;

      ctx._scan = scan;

      for (const plugin of plugins) {
        if (!plugin.resolveId) continue;

        const result = await plugin.resolveId.call(ctx, rawId, importer, {
          scan,
        });

        if (!result) continue;

        return result;
      }

      return { id: rawId };
    },
    // 读取资源
    async load(id) {
      const ctx = new PluginContext();

      for (const plugin of plugins) {
        if (!plugin.load) continue;
        const result = await plugin.load.call(ctx, id);

        if (result !== null) {
          return result;
        }
      }

      return null;
    },
    // 转换内容
    async transform(code, id) {
      const ctx = new PluginContext();

      for (const plugin of plugins) {
        if (!plugin.transform) continue;

        const result = await plugin.transform.call(ctx, code, id);

        if (!result) continue;

        code = result.code || result;
      }

      return { code };
    },
  };

  return container;
}

本次的插件容器创建时机也不同于预构建,是在创建 HTTP 服务的时候创建的,同时这里我们还补全了之前提到的 configureServer 钩子的调用:

javascript 复制代码
export async function createServer(inlineConfig) {
  // ...
  const container = await createPluginContainer(config); // 1、创建插件容器
  const server = {
    pluginContainer: container,
    // ...
  };

  for (const plugin of config.plugins) {
    if (plugin.configureServer) {
      await plugin.configureServer(server); // 2、调用插件的 configureServer 钩子
    }
  }
  // ...
}

preAliasPlugin 和 importAnalysis 插件

正如上面所说,插件容器组合了整个转换过程,而这些插件是什么时候添加的呢?其实一开始解析配置的时候就已经添加好了。

在解析配置的过程中,将会调用 resolvePlugins() 来获得最终配置的插件列表:

javascript 复制代码
import { resolvePlugins } from './plugins/index.js';

export async function resolveConfig(config = {}) {
  // ...
  const resolved = {
    root,
    publicDir,
    cacheDir,
    plugins: [],
  };

  resolved.plugins = await resolvePlugins(resolved);

  return resolved;
}

其中 resolvePlugins() 函数使用到的插件主要包括以下 3 个:

javascript 复制代码
import { preAliasPlugin } from './preAlias.js';
import { importAnalysisPlugin } from './importAnalysis.js';
import { resolvePlugin } from './resolve.js';

export async function resolvePlugins(config) {
  return [
    preAliasPlugin(config),
    resolvePlugin({ root: config.root, asSrc: true }),
    importAnalysisPlugin(config),
  ];
}

其中 resolvePlugin 插件前面我们已经提到过了,接下来我们看下 preAliasPlugin 插件,它主要负责读取之前优化器产生的缓存,如果存在则返回对应的转化后的资源路径:

javascript 复制代码
const bareImportRE = /^(?![a-zA-Z]:)[\w@](?!.*:\/\/)/;

export function preAliasPlugin(config) {
  return {
    name: 'vite:pre-alias',
    async resolveId(id, importer, options) {
      // 之前创建优化器时我们将其存入了 depsOptimizerMap 中,getDepsOptimizer() 将会读取判断是否存在优化器
      const depsOptimizer = getDepsOptimizer(config);

      if (
        importer &&
        depsOptimizer &&
        bareImportRE.test(id) &&
        !options?.scan
      ) {
        // tryOptimizedResolve
        await depsOptimizer.scanProcessing; // 等待依赖扫描完成
        const metadata = depsOptimizer.metadata;
        const depInfo = optimizedDepInfoFromId(metadata, id); // 根据 id 获取对应依赖项的信息

        if (depInfo) return depsOptimizer.getOptimizedDepId(depInfo);
      }
    },
  };
}

而另外一个 importAnalysis 插件,它主要是通过 es-module-lexer 和 magic-string 模块来对文件中的资源引用进行处理:

javascript 复制代码
import { init, parse } from 'es-module-lexer';
import MagicString from 'magic-string';

export function importAnalysisPlugin(config) {
  const { root } = config;
  let server = null;
  return {
    name: 'vite:import-analysis',
    configureServer(_server) {
      server = _server;
    },
    async transform(source, importer, options) {
      await init;
      let imports = parse(source)[0];
      let ms = new MagicString(source);
      const normalizeUrl = async (url) => {
        // 调用插件上下文上的 resolve 方法,对于具有预构建的模块
        // 就会通过上面的 preAliasPlugin 插件来获取到对应优化后的资源地址
        const resolved = await this.resolve(url, importer);
        if (resolved.id.startsWith(root + '/')) {
          url = resolved.id.slice(root.length);
        }
        return url;
      };
      for (let index = 0; index < imports.length; index++) {
        const { s: start, e: end, n: specifier } = imports[index];
        if (specifier) {
          const normalizedUrl = await normalizeUrl(specifier);
          if (normalizedUrl !== specifier) {
            ms.overwrite(start, end, normalizedUrl); // 替换资源的引用地址
          }
        }
      }
      return ms.toString();
    },
  };
}

到此,当我们在入口文件中引入 Vue 等第三方模块时就会自动去获取预构建中的结果了。

处理 Vue 文件

为了让 esbuild 认识 Vue 文件,和处理 HTML 一样,在预构建的部分创建 esbuild 的插件时,我们需要为其提供单独的路径解析的方式:

js 复制代码
function esbuildScanPlugin(config, container, deps) {
  // ...
  return {
    name: 'vite:dep-scan',
    setup(build) {
      build.onResolve(
        {
          filter: /\.vue$/, // 识别 .vue
        },
        async ({ path: id, importer }) => {
          const resolved = await resolve(id, importer);

          if (resolved) {
            return {
              path: resolved.id,
              external: true,
            };
          }
        }
      );
    },
  };
}

同时在处理请求时,需要将 Vue 文件同 JavaScript 文件一样进行转换处理:

js 复制代码
export function transformMiddleware(server) {
  return async function viteTransformMiddleware(req, res, next) {
    // ...
    // 将 vue 文件一同处理
    if (/\.js|vue/.test(url)) {
      // ...
    }
    // ...
  };
}

与 JavaScript 文件不同的是,除了解析转换文件内的资源引用路径外,还需要对 Vue 组件本身进行处理,与之前插件直接内置不一样,对 Vue 组件的解析是按需的,所以需要支持用户进行配置。

加载配置文件

为此我们解析配置时,添加一个 loadConfigFromFile() 函数来加载配置文件中的内容,并将配置中的插件传递给 resolvePlugins() 函数进行整合:

js 复制代码
// src/node/config.js
export async function resolveConfig(config = {}) {
  // ...
  const { config: userConfig } = await loadConfigFromFile('vite.config.js');
  const { plugins: userPlugins = [] } = userConfig;

  resolved.plugins = await resolvePlugins(resolved, userPlugins);

  return resolved;
}

export async function loadConfigFromFile(
  configFile,
  configRoot = process.cwd()
) {
  const filePath = path.resolve(configRoot, configFile);
  const config = await import(filePath).then((module) => module.default);

  return { path: filePath, config };
}

resolvePlugins() 函数中,当前只是简单的整合到了预置的插件列表中:

js 复制代码
export async function resolvePlugins(config, userPlugins) {
  return [
    preAliasPlugin(config),
    resolvePlugin({ root: config.root, asSrc: true }),
    ...userPlugins, // 用户配置的插件
    importAnalysisPlugin(config),
  ];
}

Vue 插件

接着我们并可以创建熟悉的配置文件并引入自定义的插件了:

js 复制代码
// vite.config.js
import vuePlugin from './plugins/vue.js';

export default {
  plugins: [vuePlugin()],
};

在自定义的插件中,对于 Vue 的处理核心逻辑主要是由 @vue/compiler-sfc 模块提供的,基于该模块会分别的 HTML 模板和脚本部分做处理,最后再合成在一起返回:

js 复制代码
import {
  parse,
  compileScript,
  rewriteDefault,
  compileTemplate,
} from 'vue/compiler-sfc'; // 记得安装哦
import fs from 'node:fs';

const descriptorCache = new Map();

export default function vuePlugin() {
  return {
    name: 'vue',
    async transform(code, id) {
      const [filename] = id.split('?');

      return filename.endsWith('.vue')
        ? await transformMain(code, filename)
        : null;
    },
  };
}

async function transformMain(source, filename) {
  const descriptor = await getDescriptor(filename);
  const scriptCode = genScriptCode(descriptor, filename);
  const templateCode = genTemplateCode(descriptor, filename);
  let resolvedCode = [
    templateCode,
    scriptCode,
    `_sfc_main['render'] = render`,
    `export default _sfc_main`,
  ].join('\n');

  return { code: resolvedCode };
}

async function getDescriptor(filename) {
  let descriptor = descriptorCache.get(filename);

  if (descriptor) return descriptor;

  const content = await fs.promises.readFile(filename, 'utf8');
  const result = parse(content, { filename });

  descriptor = result.descriptor;
  descriptorCache.set(filename, descriptor);

  return descriptor;
}

function genTemplateCode(descriptor, id) {
  const content = descriptor.template.content;
  const result = compileTemplate({ source: content, id });

  return result.code;
}

function genScriptCode(descriptor, id) {
  let scriptCode = '';
  let script = compileScript(descriptor, { id });

  if (!script.lang) {
    scriptCode = rewriteDefault(script.content, '_sfc_main');
  }

  return scriptCode;
}

更多可参考 github.com/vitejs/vite... 中的内容。

现在当我们创建一个 Vue 组件,并添加模板和脚本时也能正常工作了。

总结

Vite 完整的功能会更加全面,需要考虑的边缘 Case 也更多,对于 HMR 相关的服务是一个比较大的话题,在我们的简易版实现中先行跳过了。

简版的 Vite 主要是分析和实现了开发流程的核心流程,大家可以在这里看到 完整的源码,基于此更进一步再去阅读 Vite 的源码,希望会有所帮助。

其它

勘误

若文中理解或表述有误,欢迎指正,或 改善 此文。

参考

相关推荐
小白学习日记38 分钟前
【复习】HTML常用标签<table>
前端·html
丁总学Java1 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele1 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
懒羊羊大王呀2 小时前
CSS——属性值计算
前端·css
xgq2 小时前
使用File System Access API 直接读写本地文件
前端·javascript·面试
用户3157476081352 小时前
前端之路-了解原型和原型链
前端
永远不打烊2 小时前
librtmp 原生API做直播推流
前端
北极小狐2 小时前
浏览器事件处理机制:从硬件中断到事件驱动
前端
无咎.lsy2 小时前
vue之vuex的使用及举例
前端·javascript·vue.js
fishmemory7sec2 小时前
Electron 主进程与渲染进程、预加载preload.js
前端·javascript·electron