vite2 源码分析(一) — 启动 vite

4,758 阅读12分钟

vite源码分析 — 启动 vite

vite 框架分为了两部分,一部分是开发阶段依赖于 esbuild 构建的高效的开发体验,vite 称为 serve command;另一部分是构建阶段依赖于 rollup 编译出最终产物, vite 称为 build command。对于 build command不会做任何的介绍,只介绍目前大家比较认可的也比较核心的 serve command。

本文分析的 vite 源码版本是 v2.4.1 与目前的最新版本几乎一致,但是和 v1 版本区别很大, 所以本文的分析也是比较新的。

整体上来说,该系列文章将分为三大部分展开。首先是本文 启动 vite,这一部分会介绍vite 使用到的基础的模块,包括 config 配置处理、plugin 处理与执行、模块依赖 moduleGraph构建、预构建分析等。其中预构建是比较核心的内容。第二篇 请求资源,这一部分主要介绍资源是怎么能够通过浏览器请求的,以及 vite 服务拿到请求后是怎么处理的。第三篇 热更新,这一部分主要介绍 vite serve 模式下是怎么进行热更新的,其实和 webpack 类似。当然启动 vite 是最重要的。

前置背景

这里交代下运行的环境和简单的项目,后面的分析都是基于此项目展开的:

package.json

{
  "version": "0.0.1",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "lodash": "^4.17.21",
    "lodash-es": "^4.17.21",
    "vue": "^3.0.5"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^1.2.3",
    "@vitejs/plugin-vue-jsx": "^1.1.5",
    "@vue/compiler-sfc": "^3.0.5",
    "vite": "^2.3.7"
  }
}

vite.config.js

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx()],
});

src/main.js

import { createApp } from "vue";
import App from "./App";
import { toString, toArray } from "lodash-es";

console.log(toString(123));
console.log(toArray([]));

createApp(App).mount("#app");


src/App.jsx

import { defineComponent } from "vue";

export default defineComponent({
  setup() {
    return () => {
      return <div>hello vite</div>;
    };
  },
});

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
</head>

<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

具体怎么安装参考官方文档,大概是这样子的文件结构:

image_4SFJg6bnu2XV2ZChdvWJfZ.png

运行 vite

在项目根目录下运行 yarn run dev,也就是运行 vite 命令后,会执行 node_modules/.bin/vite 脚本,该脚本具体执行的是 node_modules/vite/dist/node/cli.js cli文件中的 command('[root]') 这也是默认执行的脚本。具体执行的是如下代码:

源码位置: vite/v2.4.1/packages/vite/src/node/cli.ts 80

 const server = await createServer({
  root,
  base: options.base,
  mode: options.mode,
  configFile: options.config,
  logLevel: options.logLevel,
  clearScreen: options.clearScreen,
  server: cleanOptions(options) as ServerOptions
});
await server.listen();

由于 我们一般直接执行 vite 不会加任何的参数, 所以等同于:

 const server = await createServer({
  root: undefined,
  base: undefined,
  mode: undefined,
  configFile: undefined,
  logLevel: undefined,
  clearScreen: undefined,
  server: undefined,
});
await server.listen();

接下来我们会详细解析 createServer 过程以及,listen函数执行的过程。其中 createServer 涉及到 vite config 配置文件初始化、构建 plugin 运行容器、初始化模块依赖、创建 server、添加比较重要的transformMiddleware。listen过程主要是执行 plugin 的 buildStart 钩子,以及执行 预构建optimizeDeps过程。这几部分下面将一一讲解。

createServer — 创建服务

下面是 createServer 的源码,对于比较重要的环节将在后面小节中一一介绍。

export async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  /**
   * 1. 初始化 config
   * 初始化配置文件config,其中包含了插件的初始化的过程,包含过滤插件、排序插件。还会执行插件的 config 钩子深度合并 config。
   */
  const config = await resolveConfig(inlineConfig, 'serve', 'development')
  const root = config.root
  const serverConfig = config.server
  const httpsOptions = await resolveHttpsConfig(config)
  let { middlewareMode } = serverConfig
  if (middlewareMode === true) {
    middlewareMode = 'ssr'
  }
  /**
   * 2. 创建 connect 服务
   */
  const middlewares = connect() as Connect.Server
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(serverConfig, middlewares, httpsOptions)
  
    // hmr 相关现在不做介绍
  const ws = createWebSocketServer(httpServer, config, httpsOptions)
  const { ignored = [], ...watchOptions } = serverConfig.watch || {}
  const watcher = chokidar.watch(path.resolve(root), {
    ignored: ['**/node_modules/**', '**/.git/**', ...ignored],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
    ...watchOptions
  }) as FSWatcher

  const plugins = config.plugins
  /**
   * 3. 创建插件运行容器,其中比较重要的钩子:buildStart、resolveId、load、transform
   */
  const container = await createPluginContainer(config, watcher)
  /**
   * 4. 创建模块依赖关系,用来描述模块之间互相依赖的关系,其中每个模块包含 id、url、file 标示,
   * importer 模块引入了哪些模块、importedModules 被那些模块所引用 transformResult 包含 code、map、etag
   */
  const moduleGraph = new ModuleGraph(container) // 模块依赖之间管理的工具 
  const closeHttpServer = createServerCloseFn(httpServer)

  // eslint-disable-next-line prefer-const
  let exitProcess: () => void

  /**
   * 创建 server
   */
  const server: ViteDevServer = {
    config: config,
    middlewares,
    get app() {
      config.logger.warn(
        `ViteDevServer.app is deprecated. Use ViteDevServer.middlewares instead.`
      )
      return middlewares
    },
    httpServer,
    watcher,
    pluginContainer: container,
    ws,
    moduleGraph,
    transformWithEsbuild,
    transformRequest(url, options) {
      return transformRequest(url, server, options)
    },
    transformIndexHtml: null as any,
    ssrLoadModule(url) {
      if (!server._ssrExternals) {
        server._ssrExternals = resolveSSRExternal(
          config,
          server._optimizeDepsMetadata
            ? Object.keys(server._optimizeDepsMetadata.optimized)
            : []
        )
      }
      return ssrLoadModule(url, server)
    },
    ssrFixStacktrace(e) {
      if (e.stack) {
        e.stack = ssrRewriteStacktrace(e.stack, moduleGraph)
      }
    },
    listen(port?: number, isRestart?: boolean) {
      return startServer(server, port, isRestart)
    },
    async close() {
      process.off('SIGTERM', exitProcess)

      if (!process.stdin.isTTY) {
        process.stdin.off('end', exitProcess)
      }

      await Promise.all([
        watcher.close(),
        ws.close(),
        container.close(),
        closeHttpServer()
      ])
    },
    _optimizeDepsMetadata: null,
    _ssrExternals: null,
    _globImporters: {},
    _isRunningOptimizer: false,
    _registerMissingImport: null,
    _pendingReload: null
  }

  server.transformIndexHtml = createDevHtmlTransformFn(server)

  exitProcess = async () => {
    try {
      await server.close()
    } finally {
      process.exit(0)
    }
  }

  process.once('SIGTERM', exitProcess)

  if (process.env.CI !== 'true') {
    process.stdin.on('end', exitProcess)
    process.stdin.resume()
  }

  watcher.on('change', async (file) => {
    file = normalizePath(file)
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    if (serverConfig.hmr !== false) {
      try {
        await handleHMRUpdate(file, server)
      } catch (err) {
        ws.send({
          type: 'error',
          err: prepareError(err)
        })
      }
    }
  })

  watcher.on('add', (file) => {
    handleFileAddUnlink(normalizePath(file), server)
  })

  watcher.on('unlink', (file) => {
    handleFileAddUnlink(normalizePath(file), server, true)
  })

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

  // Internal middlewares ------------------------------------------------------
  //......

  /**
   *  5. 添加 transformMiddleware, 这个 middleware 是与浏览器请求模块相关的
   */
  middlewares.use(transformMiddleware(server))

  // run post config hooks
  // This is applied before the html middleware so that user middleware can
  // serve custom content instead of index.html.
  postHooks.forEach((fn) => fn && fn())


  const runOptimize = async () => {
    if (config.cacheDir) {
      server._isRunningOptimizer = true
      try {
        server._optimizeDepsMetadata = await optimizeDeps(config)
      } finally {
        server._isRunningOptimizer = false
      }
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
  }

  if (!middlewareMode && httpServer) {
    // overwrite listen to run optimizer before server start
    const listen = httpServer.listen.bind(httpServer)
    /**
     * 6. 监听服务回调,执行 插件的 buildStart 钩子之外很重要的是执行 optimizeDeps 预构建的过程
     */
    httpServer.listen = (async (port: number, ...args: any[]) => {
      try {
        await container.buildStart({})
        await runOptimize()
      } catch (e) {
        httpServer.emit('error', e)
        return
      }
      return listen(port, ...args)
    }) as any

    httpServer.once('listening', () => {
      // update actual port since this may be different from initial value
      serverConfig.port = (httpServer.address() as AddressInfo).port
    })
  } else {
    await container.buildStart({})
    await runOptimize()
  }

  return server
}

resolveConfig — 初始化 config

源码位置:vite/v2.4.1/packages/vite/src/node/config.ts

createServer 执行的第一行就是调用 resolveConfig的方法得到初始化好了的 config。

const config = await resolveConfig(inlineConfig, 'serve', 'development');

具体resolveConfig代码是比较简单的与主流程没有太大的关系, 最重要的就是关于 plugins 处理。

初始化插件

把关于 plugin 代码摘取出来:

// 1.过滤出开发阶段(command 为 serve)所需要的 plugin
const rawUserPlugins = (config.plugins || []).flat().filter((p) => {
  return p && (!p.apply || p.apply === command) // command 为 serve 或 build
});
// 2.获取 pre、normal、post 类型plugin
const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawUserPlugins);
// 3.对 plugin 进行排队
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins];
// 从前到后的执行每一个用户自定义plugin 中 config 函数, 然后 merge 到现有的 config 中得到新的 config
for (const p of userPlugins) {
  if (p.config) {
    const res = await p.config(config, configEnv) // 执行 config 钩子 用来更新 配置信息,得到新的 config 与现有的 config merge
    if (res) {
      config = mergeConfig(config, res)
    }
  }
}
// 4.和 vite 内置的 plugin 一起排序, 得到最终的需要执行的 plugin
resolved.plugins = [
  // alias 处理相关的插件
  preAliasPlugin(),
  aliasPlugin({ entries: config.resolve.alias }),
  // 用户定义的 pre 类型的插件
  ...prePlugins,
  // 核心插件,主要是处理请求文件相关
  dynamicImportPolyfillPlugin(config),
  resolvePlugin({
    ...config.resolve,
    root: config.root,
    isProduction: config.isProduction,
    isBuild,
    ssrTarget: config.ssr?.target,
    asSrc: true
  }),
  htmlInlineScriptProxyPlugin(),
  cssPlugin(config),
  config.esbuild !== false ? esbuildPlugin(config.esbuild) : null,
  jsonPlugin(
    {
      namedExports: true,
      ...config.json
    },
    isBuild
  ),
  wasmPlugin(config),
  webWorkerPlugin(config),
  assetPlugin(config),
  // 用户定义的插件
  ...normalPlugins,
  // 构建用的插件
  definePlugin(config),
  cssPostPlugin(config),
  // 构建之后需要运行的插件
  ...postPlugins,
  // 后置插件 生成 最小化,manifest,报告
  clientInjectionsPlugin(config),
  importAnalysisPlugin(config)
].filter(Boolean);

处理plugin 大致步骤为:

  1. rawUserPlugins:运行到 rawUserPlugins 时候 config.plugins 指代的是用户 vite 配置文件中传入的 plugin, 我们 plugins为 [vue(), vueJsx()], 具体 plugin 有哪些属性可以参考 vite插件。这里过滤出 插件 apply 属性为 command 也就是 'serve' 的 plugin,如果没有设置 apply 则默认在 serve、build 模式下都要执行。最终得到 rawUserPlugins,由于 vue、vueJsx apply都为 空, 所以 rawUserPlugins 还是 [vue(), vueJsx()]。

  2. sortUserPlugins:得到的此刻需要执行到的插件后,根据插件 enforce 字段将插件分为 pre、normal、post 类型。并按照此顺序得到最终的用户插件列表 userPlugins。

  3. 执行插件 config 钩子:按照 userPlugins 插件列表的顺序,执行插件的 config 钩子函数,返回的对象深度合并到现有的 config 中。举一个使用 config 钩子的最用, 比如 vue 钩子在构建过程中是需要一些全局变量的比如:__VUE_OPTIONS_API__, 那么 config 钩子就可以这么写:

    config(config) {
      return {
        define: {
          __VUE_OPTIONS_API__: true,
          __VUE_PROD_DEVTOOLS__: false
        }
      };
    }
    

    这样子 define 字段就会合并到 config 对象中。

  4. 得到最终的 plugins:将 vite 一些内置的插件与用户自定义的插件按照 Alias、pre类型用户插件、Vite 核心插件、normal 类型的用户插件、Vite 构建用的插件、post类型用户插件、Vite 后置构建插件(最小化,manifest,报告)顺序组成最终的 plugin列表。

最终 config

最终我们得到的 config 为:

{
  base:'/',
  // 项目的根目录, 一般是项目目录
  root:'……',
  // 构建缓存的文件夹, 默认情况下是项目目录中 node_modules 下的.vite文件
  cacheDir:'……/node_modules/.vite',
  // 命令模式是 serve 还是 build
  command:'serve',
  // 环境 development、 production,与 process.env.NODE_ENV 一致
  mode:'development',  
  // vite 配置文件位置
  configFile:'……/vite.config.js',
  // server 需要运行的 public 文件夹
  publicDir:'……/public',
  configFileDependencies:['vite.config.js'],
  // 这个就是用户自定义使用 vue 插件设置的配置属性,在 vue 插件中被使用
  define:{
    __VUE_OPTIONS_API__: true, 
    __VUE_PROD_DEVTOOLS__: false
  },
  env:{
    BASE_URL: '/', 
    MODE: 'development', 
    DEV: true, 
    PROD: false
  },
  esbuild:{include: /\\.ts$/},
  // createServer 中传入命令行参数, 都为 undefined
  inlineConfig:{root: undefined, base: undefined, mode: undefined, configFile: undefined, logLevel: undefined, …},
  isProduction: false,
  optimizeDeps:{esbuildOptions: {…}}
  // 插件集合,结果为上面介绍的插件列表
  plugins:[],
  ……
}

createPluginContainer — 创建 plugin 运行容器

源码位置:vite/v2.4.1/packages/vite/src/node/server/pluginContainer.ts

在 createServer 得到 config 后会进行创建插件运行环境。

首先 vite 中的插件系统是使用 rollup 插件系统的,只不过对 rollup 插件系统进行了特殊的封装,比如插件列表运行环境也就是 rollup context 插件运行容器概念,vite 进行了特殊的封装,对于有些钩子比如 setAssetSource、getFileName 直接抛出 warn 是不能使用的。对于 moduleParsed等钩子因为解析 AST 性能损耗是不会在开发中使用的。同时还增加或者重写的如下几个钩子:buildStart、resolveId、load、transform、buildEnd、closeBundle。下面介绍几个比较重要钩子的源码:

buildStart

  async buildStart() {
    await Promise.all(
      plugins.map((plugin) => {
        if (plugin.buildStart) {
          return plugin.buildStart.call(
            new Context(plugin),
            container.options
          )
        }
      })
    )
  }

这个钩子主要目的是在服务启动的时候 构建之前 需要执行的一些工作。会异步并发的执行所有钩子的 buildStart 方法。

resolveId

async resolveId(rawId, importer = join(root, 'index.html')) {
  let id = null;
  let partial = {};
  for (const plugin of plugins) {
    if (!plugin.resolveId) continue
    const result = await plugin.resolveId.call(
      ctx, // plugin container 环境,可以在 resolveId 中方法一些环境变量
      rawId,
      importer,
      {}
    )
    if (!result) continue;

    if (typeof result === 'string') {
      id = result;
    } else {
      id = result.id;
      Object.assign(partial, result);
    }
    break;
  }

  if (id) {
    partial.id = isExternalUrl(id) ? id : normalizePath(id);
    return partial;
  } else {
    return null;
  }
}

这个钩子在每个传入模块请求时被调用,主要是为了获取模块的对于地址唯一标识的,执行顺序是按照插件列表的顺序串行的执行每一个插件的 resolveId 方法, 如果有返回 id 则直接返回,否则继续执行,都执行完没有 id 则返回 null。

load


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

该钩子在每个传入模块请求时被调用,主要是为了加载资源的,按照插件顺序串行的执行每一个钩子的 load 方法,如果有返回则直接返回,否则都执行完没有返回,返回 null。

transform

async transform(code, id, inMap) {
  for (const plugin of plugins) {
    if (!plugin.transform) continue
    let result;
    try {
      result = await plugin.transform.call(ctx, code, id);
    } catch (e) {
      ctx.error(e)
    }
    if (!result) continue;
    if (typeof result === 'object') {
      code = result.code || ''
      if (result.map) ctx.sourcemapChain.push(result.map) // sourcemap相关
    } else {
      code = result
    }
  }
  return {
    code,
    map: ctx._getCombinedSourcemap()
  }
},

该钩子在每个传入模块请求时被调用,主要是为了对加载的源代码进行处理的,接受文件源代码,然后按照插件顺序串行的依次执行 transform 方法,前一个插件处理 code 结果作为后一个插件输入指导所有的插件都执行完毕为止,在此过程中还会使用 rollup 能力生成对应的 sourcemap。

关于 vite 比较重要的插件运行容器就介绍这么多了,这几个插件钩子的调用会在后续分析中继续深入的解释,现在先对 plugin coantainer 以及每一个阶段有一个大概的印象。

ModuleGraph — 模块依赖管理

管理文件依赖的结构是 ModuleGraph,对于每一个 module,比如上图中的 index、a、b、c 他们的 module 对象数据结构为:

ModuleNode {
  url: string
  id: string
  file: string
  type: 'js' | 'css'
  importers = new Set<ModuleNode>() // mod 被那些模块引入
  importedModules = new Set<ModuleNode>() // mod 引入了那几个模块
  acceptedHmrDeps = new Set<ModuleNode>() // hmr accepted 接受了那几个模块
  transformResult: TransformResult | null = null  // code map etag
}

其中 id 是通过引入模块的地址经过 plugin 的 resolveId 方法后得到的唯一标识。 url 只不过是相对于 root 得到的地址, 在浏览器中解析的 js 文件中的 import 地址就是 url。 file 是将 id 所有的 search、hash 去掉之后的地址,一般就是绝对地址。

每个模块之间互相引用构成一张图,模块通过使用 importedModules 字段来维护引入了那几个模块,importers字段标识被那些模块所引用。其中 acceptedHmrDeps 是与 热更新相关的,代码中明确写某个文件是否更新被那几个模块所决定。

除了 module 和 moduleGraph 之外, 还有 idToModuleMap、 urlToModuleMap、fileToModuleMap

这几个map 建立 id、url、file 与 module 之间的映射关系, 主要是为了使得查找变得高效。这也是使用图存储结构 与 hash 索引提高查询速度的应用。

在后续介绍一个 js 是如何处理的时候介绍如下几个方法具体使用。

optimizeDeps — 预构建

源码位置:vite/v2.4.1/packages/vite/src/node/optimizer/index.ts

createServer 执行完创建 server 后执行 server.listen 函数,其中先执行了所有插件的 buildStart 钩子,然后执行 optimizeDeps 预构建的过程。为什么需要预构建呢?主要原因有两个:

1.为了性能:这个过程中主要避免模块依赖像是 lodash 包含很多模块的包导致频繁的发出多个模块请求阻塞项目正常的执行速度, 所以需要在执行下马之前进行预构建,将 dependencies 依赖包进行打包后放入到缓存文件中,提高请求速度和项目打开速度。

2. 为了兼容 cjs 模块:浏览器加载模块的时候默认都是 ESM 模块的, 但是如果依赖了一个 cjs 模块, 那么 vite 需要在预构建过程中将 cjs 模块转化成 esm 模块。

预构建的整体流程图如下,我们根据项目来一一的进行解读。

optimizeDeps 预构建过程简单理解成就是扫描项目模块中依赖于 node_modules 比如 vue lodash-es,然后将这些 deps(依赖模块)提前进行打包,然后将 deps 与 包管理的 lock 以及 vite.config 三者内容生成 hash 来唯一的标识此次的构建,如果再一次生成的 hash 不一致则说明项目依赖项发生了变化则需要放弃之前的构建重新进行预构建。

getDepHash

从 config.root 也就是项目的根目录下依次的寻找 package-lock.json、yarn.lock、pnpm-lock.yaml 文件,存在其中一个则读取文件的内容 ,将该内容与 vite.config 中的某些项(其实不用关系那些项目,只要知道是配置项就可以了)组合成字符串, 然后获取该字符串的 8 位 hash 值,得到 hash。

比较 hash

获取之前预构建的 metadata.json 文件,得到该文件中之前的 hash 值与现在生成的 hash 作比较, 如果一致,则说明项目的依赖项以及 vite 配置都没有发生变化,则预构建的内容可以直接使用就直接返回不进行接下来的预构建,否则会进入预构建的过程。

在进入接下来的构建过程中, 先清除之前构建缓存也就是清除 node_modules/.vite 文件,设置 packages.json type 为 module 方便浏览器导入加载项目模块。

scanImports

这个是第一个执行 esbuild build 的过程。在不考虑多入口的情况下,其中 build 代码如下,当然 vite 也是支持多入口的:

esbuild.build({
    write: false, // 不写入磁盘
    entryPoints: [entry],
    bundle: true,  // 需要对 js 进行打包
    format: 'esm', // 将依赖的文件都格式化城 ESM 模式
    plugins: [esbuildScanPlugin]
})

这里的 entry 可以使多个入口, 其中单入口 entry 来源主要是如下:

  let entries: string[] = []

  const explicitEntryPatterns = config.optimizeDeps?.entries
  const buildInput = config.build.rollupOptions?.input
  // 先从 optimizeDeps 中得到 entries,然后全局搜索获取 entry list
  if (explicitEntryPatterns) {
    entries = await globEntries(explicitEntryPatterns, config)
  } else if (buildInput) {
  // 再有就是从 input 中获取 entry, input 与 root 取绝对值, 所以如果项目中配置了 root 也配置了 input 要注意了避免无法找到入口没法预构建
    const resolvePath = (p: string) => path.resolve(config.root, p)
    if (typeof buildInput === 'string') {
      entries = [resolvePath(buildInput)]
    } else if (Array.isArray(buildInput)) {
      entries = buildInput.map(resolvePath)
    } else if (isObject(buildInput)) {
      entries = Object.values(buildInput).map(resolvePath)
    } else {
      throw new Error('invalid rollupOptions.input value.')
    }
  } else {
  // 默认就全局搜索 html 文件,对于 monorepo 项目或者多个 index.html 项目来说需要注意,最好指定一个明确的 entry,否则会消耗预构建的时间
    entries = await globEntries('**/*.html', config)
  }

如下是esbuildScanPlugin代码:

const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims;
const scriptRE = /(<script\b(\s[^>]*>|>))(.*?)<\/script>/gims;
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im;
const JS_TYPES_RE = /\.(?:j|t)sx?$|\.mjs$/;
const externalUnlessEntry = ({ path }) => ({
  path,
  external: !entries.includes(path) // 除了 index.html 之外都是 true, 表明不会进行下一步的构建依赖到此path模块结束了
});

const resolve = async (id: string, importer?: string) => {
  const key = id + (importer && path.dirname(importer))
  if (seen.has(key)) {
    return seen.get(key)
  }
  const resolved = await container.resolveId(
    id,
    importer && normalizePath(importer)
  )
  const res = resolved?.id
  seen.set(key, res)
  return res
}

function esbuildScanPlugin() {
return {
  name: 'vite:dep-scan',
  setup(build) {
    // onResolve-html
    // 加载 index.html 之前返回 { path: '/[root]/index.html', namespace: 'html' }
      build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
          return {
              path: await resolve(path, importer),
              namespace: 'html'
          };
      });
      // onLoad-html
      // 解析 html, 返回 { loader: 'js', contents: 'import "/src/main.js"' }
      build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => {
          let raw = fs__default.readFileSync(path, 'utf-8');
          const isHtml = path.endsWith('.html');
          const regex = scriptModuleRE;
          regex.lastIndex = 0;
          let js = '';
          let loader = 'js';
          let match;
          while ((match = regex.exec(raw))) {
              const [, openTag, htmlContent, scriptContent] = match;
              // openTag: <script type="module" src="/src/main.js">
              const srcMatch = openTag.match(srcRE);
              if (srcMatch) {
                  const src = srcMatch[1] || srcMatch[2] || srcMatch[3]; // /src/main.js
                  js += `import ${JSON.stringify(src)}\n`;
              }
          }
          return {
              loader, // js
              contents: js // import "/src/main.js"
          };
      });
      // onResolve-node_modules
      // 如果加载的模块是 单词或者@开头的一般是第三方模块,则加载解析build前会执行该 onResolve
      build.onResolve({
          // avoid matching windows volume
          filter: /^[\w@][^:]/
      }, async ({ path: id, importer }) => {
        if (depImports[id]) {
          return externalUnlessEntry({ path: id })
        }
        const resolved = await resolve(id, importer)
        if (resolved) {
          if (shouldExternalizeDep(resolved, id)) {
            return externalUnlessEntry({ path: id })
          }
          // 其中对于 node_modules 目录下的模块将放入到 depImports 数组中,对于 声明的 optimize.includs 也会作为预构建一部分
          if (resolved.includes('node_modules') || include?.includes(id)) {
            if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
              depImports[id] = resolved
            }
            return externalUnlessEntry({ path: id })
          } else {
            return {
              path: path.resolve(resolved)
            }
          }
        }
      });
      // onResolve-any
      // 对于任何的文件加载前执行该 onResolve,返回的是一个 绝对的路径,然后 esbuild 就会加载该模块
      build.onResolve({
          filter: /.*/
      }, async ({ path: id, importer }) => {
          // use vite resolver to support urls and omitted extensions
          const resolved = await resolve(id, importer);
          if (resolved) {
              if (shouldExternalizeDep(resolved, id)) {
                  return externalUnlessEntry({ path: id });
              }
              const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined;
              return {
                  path: path__default.resolve(cleanUrl(resolved)),
                  namespace
              };
          }
          else {
              // resolve failed... probably unsupported type
              return externalUnlessEntry({ path: id });
          }
      });
      // onLoad-js
     build.onLoad({ filter: JS_TYPES_RE }, ({ path: id }) => {
            let ext = path__default.extname(id).slice(1);
            if (ext === 'mjs')
                ext = 'js';
            let contents = fs__default.readFileSync(id, 'utf-8');
            if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) {
                contents = config.esbuild.jsxInject + `\n` + contents;
            }
            if (contents.includes('import.meta.glob')) {
                return transformGlob(contents, id, config.root, ext).then((contents) => ({
                    loader: ext,
                    contents
                }));
            }
            return {
                loader: ext,
                contents
            };
        });
  }
}
};

执行的过程为 esbuild 从入口 index.html文件开始执行,

  1. 首先会经过 onResolve-html 钩子, 在这个钩子返回 { path: '/[root]/index.html', namespace: 'html' }

  2. 经过 onLoad-html 钩子,解析 html 文件找到所有的 script 标签解析出 src 依赖 /src/main.js. 返回 { loader: 'js', contents: 'import "/src/main.js"' }, 然后 esbuild 会使用 js 解析引擎解析 contents 内容。

  3. /src/main.js 经过 onResolve-any 钩子,id 为文件路径, importer 为 /[root]/index.html 路径, 然后根据 id 与 importer 算出 main.js 的绝对路径,由于 main 是 js 所以直接返回 { path: '[root]/src/main.js', namespace: undefined },

  4. 进入 onLoad-js 钩子,返回 { loader:'js', contents: main 文件内容 } , esbuild使用解析 js 引擎解析 contents

  5. 解析的过程中得到 vue,则执行 onResolve-node_modules 钩子,id 为 vue, 将 vue 解析出绝对路径为node_modules/vue/dist/vue.runtime.esm-bundler.js, 所以需要将 depImports 中添加 key 为 vue, 值为绝对路径。返回 { path, external: false},返回 { path, external: false},注意的是获取包的依赖 resolve 函数中调用了 插件容器的 resolveId 方法获取到的, 具体如何获取地址在第二篇中具体介绍。

  6. 处理 './App' 导入,使用 onResolve—any 钩子得到 { path: '[root]/src/App.jsx',namespace},然后进入 onLoad-js 钩子返回 { loader:'jsx', contents: App内容 }, esbuild会通过 jsx 解析模块对App 文件进行解析。

  7. 对于 lodash-es 等过程和 vue 类似。

最终得到的 deps 为:

{
  vue: "[root]/node_modules/vue/dist/vue.runtime.esm-bundler.js",
  "lodash-es": "[root]/node_modules/lodash-es/lodash.js",
}

总之就是分析项目中依赖的第三方模块, 然后将模块名作为 key,绝对地址作为 value 得到 deps 对象。

browserHash

然后 browserHash 就是 通过将 hash 与 deps 组成的字符串后获取的 8 位hash 值。浏览器文件的请求会携带该 hash, 如果 hahs 不一致则会重新进行构建。

所以这里的预构建文件缓存时效性取决于:一、packages.json 中的 dependecies 第三方库,二、包管理的 lock 文件,三、vite 配置项。

esbuild.build

const result = await esbuild.build({
    entryPoints: Object.keys(deps), // ['vue', 'lodash-es']
    bundle: true,
    format: 'esm',
    splitting: true,
    sourcemap: true,
    outdir: "[root]/node_modules/.vite",
    treeShaking: 'ignore-annotations',
    metafile: true,
    plugins: [
        esbuildDepPlugin(deps, flatIdToExports, config)
    ]
});

在执行 build 之前,会经过 esbuild parser 过程分析 deps 的每一个模块,得到该模块的依赖项有哪些以及是否有导出等保存在 flatIdToExports中, flatIdToExports 文件就是描述了 deps 这些依赖项每一项的依赖了那些文件导出了那些变量。

然后执行 esbuild build 的过程,入口文件为每一个 deps, 构建产出的文件在 .vite目录中。具体的构建过程是使用 esbuildDepPlugin 插件定义的。

esbuildDepPlugin插件的代码如下:

function esbuildDepPlugin(qualified, exportsData, config) {
  return {
    name: "vite:dep-pre-bundle",
    setup(build) {
      // onResolve-node_modules
      build.onResolve({ filter: /^[\w@][^:]/ }, async ({ path: id, importer, kind, resolveDir }) => {
        // 如果是 entry 也就是 vue、lodash-es,模块返回 { path: vue/lodash-es, namespaces: 'dep' }
        if (id in qualified) {
          return {
            path: flatId,
            namespace: "dep",
          };
        }
        // 对于非入口模块,回去绝对路径,返回 { path: 绝对路径 },然后 esbuild 会解析该模块
        const resolved = await resolve(id, importer, kind);
        if (resolved) {
          return {
            path: path__default.resolve(resolved),
          };
        }
      });

      // onLoad-entry
      build.onLoad({ filter: /.*/, namespace: "dep" }, ({ path: id }) => {
        //获取入口模块的相对路径
        const entryFile = qualified[id];
        let relativePath = normalizePath$4(path__default.relative(root, entryFile));
        if (!relativePath.startsWith(".")) {
          relativePath = `./${relativePath}`;
        }
        let contents = "";
        const data = exportsData[id];
        const [imports, exports] = data;
        // 如果是一个 cjs 模块, 则返回的 code 为 `export default require("${relativePath}");
        if (!imports.length && !exports.length) {
          // cjs
          contents += `export default require("${relativePath}");`;
        } else {
          // 如果是 esm 模块,且包含默认导出,则 code 为 import d from "${relativePath}";export default d;
          if (exports.includes("default")) {
            contents += `import d from "${relativePath}";export default d;`;
          }
          // 如果有 export 导出其他变量,则  export * from "${relativePath}"
          if (data.hasReExports || exports.length > 1 || exports[0] !== "default") {
            contents += `\nexport * from "${relativePath}"`;
          }
        }
        let ext = path__default.extname(entryFile).slice(1);
        if (ext === "mjs") ext = "js";
        // esbuild 解析该 content
        return {
          loader: ext,
          contents,
          resolveDir: root,
        };
      });
    },
  };
}


首先加载的模块式入口模块也就是 deps 中的模块,会生成一个总的入口文件,具体生成规则:export default require("${relativePath}");

  • 没有导入导出认为是一个 cjs 则直接返回内容为 。

  • 如果有导入导出认为是 ESM:

    • 如果只有一个 default导出则:import d from "${relativePath}";export default d;

    • 如果包含多个导出:export * from "${relativePath}"

返回 { loder: js, contents:code, resolveDir: root }

这么做的目的是方式将重复的文件打包到多个 chunk 中。然后会解析deps 文件内容,将该文件相关的所有的导入文件都打包到一个 chunk 中。比如 vue 打包输出的模块就包含了 '@vue/runtime-core' '@vue/runtime-common' '@vue/shared' '@vue/reactivity'等内容。

生成最终的 metadata 文件,写入到缓存目录 .vite 中。

{
  hash: "b0f10227",
  browserHash: "3d578768",
  optimized: {
    vue: {
      file: "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/.vite/vue.js",
      src: "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      needsInterop: false,
    },
    "lodash/toString": {
      file: "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/.vite/lodash_toString.js",
      src: "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/lodash/toString.js",
      needsInterop: true, // 标识一个 cjs 文件, 需要先构建依赖项才行
    },
    "lodash/toArray": {
      file: "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/.vite/lodash_toArray.js",
      src: "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/lodash/toArray.js",
      needsInterop: true,
    },
  },
}

至此预构建的流程就这么多了。总结一下预构建的过程就是,每一次预构建都有唯一的 hash 与 browerHash,包管理的 lock 文件、vie.config 配置项 甚至项目中使用到的第三方库决定了该 hash 值。如果三者有其中一个发生变化则 hash 发生改变需要重新进行预构建。这也是 vite 文件缓存的策略机制。对于预构建来说,通过 scanImport 扫描出项目代码依赖的 node_modules 中的第三方包,然后将这些包作为入口进行 esbuild 打包得到打包后的预构建资源,最终将打包内容描述 optimized 以及 hash 写入到 metadata.json 中。