3
本文基于vite 4.3.0-beta.1版本的源码进行分析

文章内容

  1. vite 本地服务器的创建流程分析
  2. vite 预构建流程分析
  3. vite middlewares拦截请求资源分析
  4. vite 热更新HMR流程分析

1. 入口npm run dev

在项目的package.json中注册对应的scripts命令,当我们运行npm run dev时,本质就是运行了vite

{
    "scripts": {
      "dev": "vite",
    }
}
vite命令是在哪里注册的呢?

node_modules/vite/package.json

{
    "bin": {
        "vite": "bin/vite.js"
    }
}

node_modules/vite/bin/vite.js

#!/usr/bin/env node
const profileIndex = process.argv.indexOf('--profile')

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

if (profileIndex > 0) {
    //...
} else {
  start()
}

最终调用的是打包后的dist/node/cli.js文件

截屏2023-04-03 02.28.01.png

处理用户的输入后,调用./chunks/dep-f365bad6.jscreateServer()方法,如下面所示,最终调用server.listen()

const { createServer } = await import('./chunks/dep-f365bad6.js').then(function (n) { return n.G; });
const server = await createServer({
    root,
    base: options.base,
    mode: options.mode,
    configFile: options.config,
    logLevel: options.logLevel,
    clearScreen: options.clearScreen,
    optimizeDeps: { force: options.force },
    server: cleanOptions(options),
});
await server.listen();

createServer()

async function createServer(inlineConfig = {}) {
    const config = await resolveConfig(inlineConfig, 'serve');
    if (isDepsOptimizerEnabled(config, false)) {
        // start optimizer in the background, we still need to await the setup
        await initDepsOptimizer(config);
    }
    const { root, server: serverConfig } = config;
    const httpsOptions = await resolveHttpsConfig(config.server.https);
    const { middlewareMode } = serverConfig;
    const resolvedWatchOptions = resolveChokidarOptions(config, {
        disableGlobbing: true,
        ...serverConfig.watch,
    });
    const middlewares = connect();
    const httpServer = middlewareMode
        ? null
        : await resolveHttpServer(serverConfig, middlewares, httpsOptions);
    const ws = createWebSocketServer(httpServer, config, httpsOptions);

    const watcher = chokidar.watch(
    // config file dependencies and env file might be outside of root
    [root, ...config.configFileDependencies, path$o.join(config.envDir, '.env*')], resolvedWatchOptions);
    const moduleGraph = new ModuleGraph((url, ssr) => container.resolveId(url, undefined, { ssr }));
   
    const server = {
        config,
        middlewares,
        httpServer,
        watcher,
        pluginContainer: container,
        ws,
        moduleGraph,
        ...
    };
    
    const initServer = async () => {
        if (serverInited)
            return;
        if (initingServer)
            return initingServer;
        initingServer = (async function () {
            await container.buildStart({});
            initingServer = undefined;
            serverInited = true;
        })();
        return initingServer;
    };
    if (!middlewareMode && httpServer) {
        // overwrite listen to init optimizer before server start
        const listen = httpServer.listen.bind(httpServer);
        httpServer.listen = (async (port, ...args) => {
            try {
                await initServer();
            }
            catch (e) {
                httpServer.emit('error', e);
                return;
            }
            return listen(port, ...args);
        });
    }
    else {
        await initServer();
    }
    return server;
}

createServer()源码太长,下面将分为多个小点进行分析,对于一些不是该点分析的代码将直接省略:

  • 创建本地node服务器
  • 预构建
  • 请求资源拦截
  • 热更新HMR

createServe思维导图

createServer概述.svg

2. 创建本地node服务器

// 只保留本地node服务器的相关代码
async function createServer(inlineConfig = {}) {
    // 创建http请求
    const middlewares = connect();
    const httpServer = middlewareMode
        ? null
        : await resolveHttpServer(serverConfig, middlewares, httpsOptions);

    const server = {
        config,
        middlewares,
        httpServer,
        watcher,
        pluginContainer: container
        ...,
        async listen(port, isRestart) {
            await startServer(server, port);
            if (httpServer) {
                server.resolvedUrls = await resolveServerUrls(httpServer, config.server, config);
                if (!isRestart && config.server.open)
                    server.openBrowser();
            }
            return server;
        }
    }
    const initServer = async () => {
        if (serverInited)
            return;
        if (initingServer)
            return initingServer;
        initingServer = (async function () {
            await container.buildStart({});
            initingServer = undefined;
            serverInited = true;
        })();
        return initingServer;
    };
    
    //...
    await initServer();
    return server;
}
上面代码蕴含着多个知识点,我们下面将展开分析

2.1 connect()创建http服务器

Connect模块介绍

Connect是一个Node.js的可扩展HTTP服务框架,用于将各种"middleware"粘合在一起以处理请求

var app = connect();
app.use(function middleware1(req, res, next) {
  // middleware 1
  next();
});
app.use(function middleware2(req, res, next) {
  // middleware 2
  next();
});
var connect = require('connect');
var http = require('http');

var app = connect();

// gzip/deflate outgoing responses
var compression = require('compression');
app.use(compression());

// respond to all requests
app.use(function(req, res){
  res.end('Hello from Connect!\n');
});

//create node.js http server and listen on port
http.createServer(app).listen(3000);

源码分析

会先使用connect()创建middlewares,然后将middlewares作为app属性名传入到resolveHttpServer()
最终也是使用Node.jsHttp模块创建本地服务器

// 只保留本地node服务器的相关代码
async function createServer(inlineConfig = {}) {
    const middlewares = connect();
    const httpServer = middlewareMode
        ? null
        : await resolveHttpServer(serverConfig, middlewares, httpsOptions);
   //...
}
async function resolveHttpServer({ proxy }, app, httpsOptions) {
    if (!httpsOptions) {
        const { createServer } = await import('node:http');
        return createServer(app);
    }
    // #484 fallback to http1 when proxy is needed.
    if (proxy) {
        const { createServer } = await import('node:https');
        return createServer(httpsOptions, app);
    }else {
        const { createSecureServer } = await import('node: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);
    }
}

2.2 启动http服务器

dist/node/cli.js文件的分析中,我们知道
在创建server完成后,我们会调用server.listen()

// dist/node/cli.js
const { createServer } = await import('./chunks/dep-f365bad6.js').then(function (n) { return n.G; });
const server = await createServer({
    root,
    base: options.base,
    mode: options.mode,
    configFile: options.config,
    logLevel: options.logLevel,
    clearScreen: options.clearScreen,
    optimizeDeps: { force: options.force },
    server: cleanOptions(options),
});
await server.listen();

server.listen()最终调用的也是Node.jsHttp模块的监听方法,即上面Connect模块介绍示例中的http.createServer(app).listen(3000)

async function createServer(inlineConfig = {}) {
    const middlewares = connect();
  const httpServer = middlewareMode
      ? null
      : await resolveHttpServer(serverConfig, middlewares, httpsOptions);
  const server = {
      httpServer,
      //...
      async listen(port, isRestart) {
          await startServer(server, port);
          if (httpServer) {
              server.resolvedUrls = await resolveServerUrls(httpServer, config.server, config);
              if (!isRestart && config.server.open)
                  server.openBrowser();
          }
          return server;
      }
  };
  }
  async function startServer(server, inlinePort) {
      const httpServer = server.httpServer;
      //...
      await httpServerStart(httpServer, {
          port,
          strictPort: options.strictPort,
          host: hostname.host,
          logger: server.config.logger,
      });
  }
  async function httpServerStart(httpServer, serverOptions) {
      let { port, strictPort, host, logger } = serverOptions;
      return new Promise((resolve, reject) => {
          httpServer.listen(port, host, () => {
              httpServer.removeListener('error', onError);
              resolve(port);
          });
      });
  }

3. 预构建

3.1 预构建的原因

CommonJS 和 UMD 兼容性

在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
在转换 CommonJS 依赖项时,Vite 会进行智能导入分析,这样即使模块的导出是动态分配的(例如 React),具名导入(named imports)也能正常工作:

// 符合预期
import React, { useState } from 'react'

性能

为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。
有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。
通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!

注意
依赖预构建仅适用于开发模式,并使用 esbuild 将依赖项转换为 ES 模块。在生产构建中,将使用 @rollup/plugin-commonjs

3.2 预构建整体流程(流程图)

预构建整体流程.svg

接下来会根据流程图的核心流程进行源码分析

3.3 预构建整体流程(源码整体概述)

Vite 会将预构建的依赖缓存到 node_modules/.vite。它根据几个源来决定是否需要重新运行预构建步骤:

  • 包管理器的 lockfile 内容,例如 package-lock.json,yarn.lock,pnpm-lock.yaml,或者 bun.lockb
  • 补丁文件夹的修改时间
  • 可能在 vite.config.js 相关字段中配置过的
  • NODE_ENV 中的值

只有在上述其中一项发生更改时,才需要重新运行预构建。

我们会先检测是否有预构建的缓存,如果没有缓存,则开始预构建:发现文件依赖并存放于deps,然后将deps打包到node_modules/.vite

async function createServer(inlineConfig = {}) {
    const config = await resolveConfig(inlineConfig, 'serve');
    if (isDepsOptimizerEnabled(config, false)) {
        // start optimizer in the background, we still need to await the setup
        await initDepsOptimizer(config);
    }
    //...
}
async function initDepsOptimizer(config, server) {
    if (!getDepsOptimizer(config, ssr)) {
        await createDepsOptimizer(config, server);
    }
}
async function createDepsOptimizer(config, server) {
    // 第一步:3.4获取缓存
    const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
    // 第二步:3.5没有缓存时进行依赖扫描
    const deps = {};
    discover = discoverProjectDependencies(config);
    const deps = await discover.result;
    // 第三步:3.6没有缓存时进行依赖扫描,然后进行依赖打包到node_modules/.vite
    optimizationResult = runOptimizeDeps(config, knownDeps);
}

3.4 获取缓存loadCachedDepOptimizationMetadata

async function createDepsOptimizer(config, server) {
    // 第一步:3.4获取缓存
    const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
    if (!cachedMetadata) {
        // 第二步:3.5没有缓存时进行依赖扫描
        discover = discoverProjectDependencies(config);
        const deps = await discover.result;
        // 第三步:3.6依赖扫描后进行打包runOptimizeDeps(),存储到node_modules/.vite
        optimizationResult = runOptimizeDeps(config, knownDeps);
    }
}
async function loadCachedDepOptimizationMetadata(config, ssr, force = config.optimizeDeps.force, asCommand = false) {
    const depsCacheDir = getDepsCacheDir(config, ssr);
    if (!force) {
        // 3.4.1 获取_metadata.json文件数据
        let cachedMetadata;
        const cachedMetadataPath = path$o.join(depsCacheDir, '_metadata.json');
        cachedMetadata = parseDepsOptimizerMetadata(await fsp.readFile(cachedMetadataPath, 'utf-8'), depsCacheDir);
        // 3.4.2 比对hash值
        if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
            return cachedMetadata;
        }
    }
    // 3.4.3 清空缓存
    await fsp.rm(depsCacheDir, { recursive: true, force: true });
}

3.4.1 获取_metadata.json文件数据

通过getDepsCacheDir()获取node_modules/.vite/deps的缓存目录,然后拼接_metadata.json数据,读取文件并且进行简单的整理parseDepsOptimizerMetadata()后形成校验缓存是否过期的数据

const depsCacheDir = getDepsCacheDir(config, ssr);
let cachedMetadata;
const cachedMetadataPath = path$o.join(depsCacheDir, '_metadata.json');
cachedMetadata = parseDepsOptimizerMetadata(await fsp.readFile(cachedMetadataPath, 'utf-8'), depsCacheDir);
下面metadata的数据结构是在_metadata.json数据结构的基础上叠加一些数据
function parseDepsOptimizerMetadata(jsonMetadata, depsCacheDir) {
    const { hash, browserHash, optimized, chunks } = JSON.parse(jsonMetadata, (key, value) => {
        if (key === 'file' || key === 'src') {
            return normalizePath$3(path$o.resolve(depsCacheDir, value));
        }
        return value;
    });
    if (!chunks ||
        Object.values(optimized).some((depInfo) => !depInfo.fileHash)) {
        // outdated _metadata.json version, ignore
        return;
    }
    const metadata = {
        hash,
        browserHash,
        optimized: {},
        discovered: {},
        chunks: {},
        depInfoList: [],
    };
    //...处理metadata
    return metadata;
}

3.4.2 比对hash值

if (cachedMetadata && cachedMetadata.hash === getDepHash(config, ssr)) {
    return cachedMetadata;
}
最终生成预构建缓存时,_metadata.json中的hash是如何计算的?是根据什么文件得到的hash值?

getDepHash()的逻辑也不复杂,主要的流程为:

  • 先进行lockfileFormats[i]文件是否存在的检测,比如存在yarn.lock,那么就直接返回yarn.lock,赋值给content
  • 检测是否存在patches文件夹,进行content += stat.mtimeMs.toString()
  • 将一些配置数据进行JSON.stringify()添加到content的后面
  • 最终使用content形成对应的hash值,返回该hash

getDepHash()的逻辑总结下来就是:

  • 包管理器的锁文件内容,例如 package-lock.json,yarn.lock,pnpm-lock.yaml,或者 bun.lockb
  • 补丁文件夹的修改时间
  • vite.config.js 中的相关字段
  • NODE_ENV 的值

只有在上述其中一项发生更改时,hash才会发生变化,才需要重新运行预构建

const lockfileFormats = [
    { name: 'package-lock.json', checkPatches: true },
    { name: 'yarn.lock', checkPatches: true },
    { name: 'pnpm-lock.yaml', checkPatches: false },
    { name: 'bun.lockb', checkPatches: true },
];
const lockfileNames = lockfileFormats.map((l) => l.name);

function getDepHash(config, ssr) {
    // 第一部分:获取配置文件初始化content
    const lockfilePath = lookupFile(config.root, lockfileNames);
    let content = lockfilePath ? fs$l.readFileSync(lockfilePath, 'utf-8') : '';
    // 第二部分:检测是否存在patches文件夹,增加content的内容
    if (lockfilePath) {
        //...
        const fullPath = path$o.join(path$o.dirname(lockfilePath), 'patches');
        const stat = tryStatSync(fullPath);
        if (stat?.isDirectory()) {
            content += stat.mtimeMs.toString();
        }
    }
    // 第三部分:将配置添加到content的后面
    const optimizeDeps = getDepOptimizationConfig(config, ssr);
    content += JSON.stringify({
        mode: process.env.NODE_ENV || config.mode,
        //...
    });
    return getHash(content);
}
function getHash(text) {
    return createHash$2('sha256').update(text).digest('hex').substring(0, 8);
}

拿到getDepHash()计算得到的hash,跟目前node_modules/.vite/deps/_metadata.jsonhash属性进行比对,如果一样说明预构建缓存没有任何改变,无需重新预构建,直接使用上次预构建缓存即可

下面是_metadata.json的示例
{
  "hash": "2b04a957",
  "browserHash": "485313cf",
  "optimized": {
    "lodash-es": {
      "src": "../../lodash-es/lodash.js",
      "file": "lodash-es.js",
      "fileHash": "d69f60c8",
      "needsInterop": false
    },
    "vue": {
      "src": "../../vue/dist/vue.runtime.esm-bundler.js",
      "file": "vue.js",
      "fileHash": "98c38b51",
      "needsInterop": false
    }
  },
  "chunks": {}
}

3.4.3 清空缓存

如果缓存过期或者带了force=true参数,代表缓存不可用,使用fsp.rm清空缓存文件夹

"dev": "vite --force"代表不使用缓存
await fsp.rm(depsCacheDir, { recursive: true, force: true });

3.5 没有缓存时进行依赖扫描discoverProjectDependencies()

async function createDepsOptimizer(config, server) {
    // 第一步:3.4获取缓存
    const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
    if (!cachedMetadata) {
        // 第二步:3.5没有缓存时进行依赖扫描
        discover = discoverProjectDependencies(config);
        const deps = await discover.result;
        // 第三步:3.6依赖扫描后进行打包runOptimizeDeps(),存储到node_modules/.vite
        optimizationResult = runOptimizeDeps(config, knownDeps);
    }
}
function discoverProjectDependencies(config) {
    const { cancel, result } = scanImports(config);
    return {
        cancel,
        result: result.then(({ deps, missing }) => {
            const missingIds = Object.keys(missing);
            return deps;
        }),
    };
}
discoverProjectDependencies实际调用就是scanImports
function scanImports(config) {
  // 3.5.1 计算入口文件computeEntries
  const esbuildContext = computeEntries(config).then((computedEntries) => {
        entries = computedEntries;
        // 3.5.2 打包入口文件esbuild插件初始化
        return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
    });
  // 3.5.3 开始打包
  const result = esbuildContext
        .then((context) => {...}
  return {result, cancel}
}

3.5.1 计算入口文件computeEntries()

官方文档关于optimizeDeps.entries可以知道,

  • 默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项(忽略node_modulesbuild.outDir__tests__coverage
  • 如果指定了build.rollupOptions?.input,即在vite.config.js中配置rollupOptions参数,指定了入口文件,Vite 将转而去抓取这些入口点
  • 如果这两者都不合你意,则可以使用optimizeDeps.entries指定自定义条目——该值需要遵循 fast-glob 模式 ,或者是相对于 Vite 项目根目录的匹配模式数组,可以简单理解为入口文件匹配的正则表达式,可以进行多个文件类型的匹配

如果使用optimizeDeps.entries,注意默认只有 node_modulesbuild.outDir 文件夹会被忽略。如果还需忽略其他文件夹,你可以在模式列表中使用以 ! 为前缀的、用来匹配忽略项的模式
optimizeDeps.entries具体的示例如下所示,详细可以参考 fast-glob 模式

  • file-{1..3}.js — matches files: file-1.js, file-2.js, file-3.js.
  • file-(1|2) — matches files: file-1.js, file-2.js.

本文中我们将直接使用默认的模式,也就是globEntries('**/*.html', config)进行分析,会直接匹配到index.html入口文件

function computeEntries(config) {
    let entries = [];
    const explicitEntryPatterns = config.optimizeDeps.entries;
    const buildInput = config.build.rollupOptions?.input;
    if (explicitEntryPatterns) {
        entries = await globEntries(explicitEntryPatterns, config);
    } else if (buildInput) {
        const resolvePath = (p) => path$o.resolve(config.root, p);
        if (typeof buildInput === 'string') {
            entries = [resolvePath(buildInput)];
        } else if (Array.isArray(buildInput)) {
            entries = buildInput.map(resolvePath);
        } else if (isObject$2(buildInput)) {
            entries = Object.values(buildInput).map(resolvePath);
        }
    } else {
        entries = await globEntries('**/*.html', config);
    }
    entries = entries.filter((entry) => isScannable(entry) && fs$l.existsSync(entry));
    return entries;
}

3.5.2 打包入口文件esbuild插件初始化prepareEsbuildScanner

在上面的分析中,我们执行完3.5.1步骤的computeEntries()后,会执行prepareEsbuildScanner()的插件准备工作

function scanImports(config) {
  // 3.5.1 计算入口文件computeEntries
  const esbuildContext = computeEntries(config).then((computedEntries) => {
        entries = computedEntries;
        // 3.5.2 打包入口文件esbuild插件初始化
        return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
    });
  // 3.5.3 开始打包
  const result = esbuildContext
        .then((context) => {...}
  return {result, cancel}
}
下面将会prepareEsbuildScanner()的流程展开分析

在计算出入口文件后,后面就是启动esbuild插件进行打包,由于打包流程涉及的流程比较复杂,我们在3.5的分析中,只会分析预构建相关的流程部分:

  • 先进行了vite插件的初始化:container = createPluginContainer()
  • 然后将vite插件container作为参数传递到esbuild插件中,后续逻辑需要使用container提供的一些能力
  • 最终进行esbuild打包的初始化,使用3.5.1 计算入口文件computeEntries拿到的入口文件作为stdin,即esbuildinput,然后将刚刚注册的plugin放入到plugins属性中
esbuild相关知识点可以参考【基础】esbuild使用详解或者官方文档
async function prepareEsbuildScanner(config, entries, deps, missing, scanContext) {
    // 第一部分: container初始化
    const container = await createPluginContainer(config);
    if (scanContext?.cancelled)
        return;
    // 第二部分: esbuildScanPlugin()
    const plugin = esbuildScanPlugin(config, container, deps, missing, entries);
    const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {};
    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: [...plugins, plugin],
        ...esbuildOptions,
    });
}
第一部分createPluginContainer
插件管理类container初始化
async function createServer(inlineConfig = {}) {
    const config = await resolveConfig(inlineConfig, 'serve');
    // config包含了plugins这个属性
    const container = await createPluginContainer(config, moduleGraph, watcher);
}
// ======= 初始化所有plugins =======start
function resolveConfig() {
    resolved.plugins = await resolvePlugins(resolved, prePlugins, normalPlugins, postPlugins);
    return resolved;
}
async function resolvePlugins(config, prePlugins, normalPlugins, postPlugins) {
    return [
        resolvePlugin({ ...}),
        htmlInlineProxyPlugin(config),
        cssPlugin(config),
        ...
    ].filter(Boolean);
}
// ======= 初始化所有plugins =======end
function createPluginContainer(config, moduleGraph, watcher) {
    const { plugins, logger, root, build: { rollupOptions }, } = config;
    const { getSortedPluginHooks, getSortedPlugins } = createPluginHookUtils(plugins);
    const container = {
        async resolveId() {
            //...使用了getSortedPlugins()这个方法,这个方法里有plugins
        }
    }
    return container;
}
function createPluginHookUtils(plugins) {
    function getSortedPlugins(hookName) {
        if (sortedPluginsCache.has(hookName))
            return sortedPluginsCache.get(hookName);
        // 根据hookName,即对象属性名,拼接对应的key-value的plugin
        const sorted = getSortedPluginsByHook(hookName, plugins);
        sortedPluginsCache.set(hookName, sorted);
        return sorted;
    }
}
function getSortedPluginsByHook(hookName, plugins) {
    const pre = [];
    const normal = [];
    const post = [];
    for (const plugin of plugins) {
        const hook = plugin[hookName];
        if (hook) {
            //...pre.push(plugin)
            //...normal.push(plugin)
            //...post.push(plugin) 
        }
    }
    return [...pre, ...normal, ...post];
}

如上面代码所示,在createServer()->resolveConfig()->resolvePlugins()的流程中,会进行vite插件的注册

vite插件具体有什么呢?

所有的插件都放在vite源码的src/node/plugins/**中,每一个插件都会有对应的name,比如下面这个插件vite:css

截屏2023-04-05 00.27.45.png

常用方法container.resolveId()

从上面插件初始化的分析中,我们可以知道,getSortedPlugins('resolveId')就是检测该插件是否有resolveId这个属性,如果有,则添加到返回的数组集合中,比如有10个插件中有5个插件具有resolveId属性,那么最终getSortedPlugins('resolveId')拿到的就是这5个插件的Array数据

因此container.resolveId()中运行插件的个数不止一个,但并不是每一个插件都能返回对应的结果result,即const result = await handler.call(...)可能为undefined

当有插件处理后result不为undefined时,会直接执行break,然后返回container.resolveId()的结果

//getSortedPlugins最终调用的就是getSortedPluginsByHook
function getSortedPluginsByHook(hookName, plugins) {
    const pre = [];
    const normal = [];
    const post = [];
    for (const plugin of plugins) {
        const hook = plugin[hookName];
        if (hook) {
            //...pre.push(plugin)
            //...normal.push(plugin)
            //...post.push(plugin) 
        }
    }
    return [...pre, ...normal, ...post];
}
async resolveId(rawId, importer = join$2(root, 'index.html'), options) {
    for (const plugin of getSortedPlugins('resolveId')) {
        if (!plugin.resolveId)
            continue;
        if (skip?.has(plugin))
            continue;
        const handler = 'handler' in plugin.resolveId
            ? plugin.resolveId.handler
            : plugin.resolveId;
        const result = await handler.call(...);
        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$3(id);
        return partial;
    } else {
        return null;
    }
}
第二部分初始化dep-scan的vite插件
esbuild插件中,提供了两种方法onResolveonLoad
onResolveonLoad的第1个参数为filter(必填)和namespaces(可选)
钩子函数必须提供过滤器filter正则表达式,但也可以选择提供namespaces以进一步限制匹配的路径

按照esbuild插件的onResolve()onLoad()流程进行一系列处理

async function prepareEsbuildScanner(...) {
    const container = await createPluginContainer(config);
    if (scanContext?.cancelled)
        return;
    const plugin = esbuildScanPlugin(...);
    //...省略esbuild打包配置
}
const htmlTypesRE = /\.(html|vue|svelte|astro|imba)$/;
function esbuildScanPlugin(config, container, depImports, missing, entries) {
    const resolve = async (id, importer, options) => {
        // 第一部分内容:container.resolveId()
        const resolved = await container.resolveId();
        const res = resolved?.id;
        return res;
    };
    return {
        name: 'vite:dep-scan',
        setup(build) {
            // 第二个部分内容:插件执行流程
            build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
                const resolved = await resolve(path, importer);
                return {
                    path: resolved,
                    namespace: 'html',
                };
            });
            build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => {...});
        //....
         }
    }
}

esbuildScanPlugin()的执行逻辑中,如上面代码块注释所示,分为两部分内容:

  • container.resolveId()的具体逻辑,涉及到container的初始化,具体的插件执行等逻辑
  • build.onResolvebuild.onLoad的具体逻辑

下面将使用简单的具体实例按照这两部分内容展开分析:
index.html->main.js->import vue的流程进行分析

index.html文件触发container.resolveId()
当我们执行入口index.html文件的打包解析时,我们通过调试可以知道,我们最终会命中插件:vite:resolve的处理,接下来我们将针对这个插件展开分析

传入参数id就是路径,比如"/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/index.html"或者"/src/main.js"
传入参数importer就是引用它的模块,比如"stdin"或者"/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/index.html"

container.resolveId()逻辑就是根据目前路径的类型,比如是绝对路径、相对路径、模块路或者其他路径类型,然后进行不同的处理,最终返回拼凑好的完整路径

return {
    name: 'vite:resolve',
    async resolveId(id, importer, resolveOpts) {
        //...

        // URL
        // /foo -> /fs-root/foo
        if (asSrc && id[0] === '/' && (rootInRoot || !id.startsWith(root))) {...}
        // relative
        if (id[0] === '.' ||
            ((preferRelative || importer?.endsWith('.html')) &&
                startsWithWordCharRE.test(id))) {...}
        // drive relative fs paths (only windows)
        if (isWindows$4 && id[0] === '/') {...}
        // absolute fs paths
        if (isNonDriveRelativeAbsolutePath(id) &&
            (res = tryFsResolve(id, options))) {...}
        // external
        if (isExternalUrl(id)) {...}
        // data uri: pass through (this only happens during build and will be
        // handled by dedicated plugin)
        if (isDataUrl(id)) {
            return null;
        }
        // bare package imports, perform node resolve
        if (bareImportRE.test(id)) {...}
    }
}
index.html文件触发onResolve和onLoad

一开始我们打包index.html入口文件时

  • 触发filter: htmlTypesRE的筛选,命中onResolve()的处理逻辑,返回namespace: 'html'和整理好的路径path传递给下一个阶段
  • 触发filter: htmlTypesREnamespace: 'html'的筛选条件,命中onLoad()的处理逻辑,使用regex.exec(raw)匹配出index.html中的<script>标签,拿出里面对应的src的值,最终返回content:"import '/src/main.js' \n export default {}"
每个未标记external:true的唯一路径/命名空间的文件加载完成会触发onLoad()回调,onLoad()的工作是返回模块的内容并告诉 esbuild 如何解释它
return {
    name: 'vite:dep-scan',
    setup(build) {
        // html types: extract script contents -----------------------------------
        build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
            const resolved = await resolve(path, importer);
            return {
                path: resolved,
                namespace: 'html',
            };
        });
        // extract scripts inside HTML-like files and treat it as a js module
        build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => {
            let raw = await fsp.readFile(path, 'utf-8');
            const isHtml = path.endsWith('.html');
            //scriptModuleRE = /(<script\b[^>]+type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gis;
            //scriptRE = /(<script(?:\s[^>]*>|>))(.*?)<\/script>/gis;
            const regex = isHtml ? scriptModuleRE : scriptRE;
            while ((match = regex.exec(raw))) {
                const [, openTag, content] = match;
                let loader = 'js';
                if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
                    loader = lang;
                } else if (path.endsWith('.astro')) {
                    loader = 'ts';
                }
                //srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;
                const srcMatch = openTag.match(srcRE);
                if (srcMatch) {
                    const src = srcMatch[1] || srcMatch[2] || srcMatch[3];
                    js += `import ${JSON.stringify(src)}\n`;
                } else if (content.trim()) {
                    //...
                }
            }
            return {
                loader: 'js',
                contents: js,
            };
        });
    }
}
onLoad()返回content:"import '/src/main.js' \n export default {}"后再度触发onResolve()的执行
main.js文件触发container.resolveId()

从上面index.html对于插件vite:resolveresolveId()分析,我们知道会进行多种模式路径的处理,而src/main.js则触发/foo -> /fs-root/foo的处理,逻辑就是合并root,然后形成最终的路径返回

name: 'vite:resolve',
async resolveId(id, importer, resolveOpts) {
    //...
    // /foo -> /fs-root/foo
    if (asSrc && id[0] === '/' && (rootInRoot || !id.startsWith(root))) {
        const fsPath = path$o.resolve(root, id.slice(1));
        if ((res = tryFsResolve(fsPath, options))) {
            return ensureVersionQuery(res, id, options, depsOptimizer);
        }
    }
}
main.js文件触发onResolve和onLoad

此时id="/src/main.js",经过resolve()后拼接成完整的路径resolved="xxxxx/vite-debugger/src/main.js",最终返回path="xxxxx/vite-debugger/src/main.js",然后触发onLoad()解析main.js的内容进行返回

build.onResolve({
    filter: /.*/,
}, async ({ path: id, importer, pluginData }) => {
    // id="/src/main.js"->"xxxxx/vite-debugger/src/main.js"
    const resolved = await resolve(id, importer, ...);
    if (resolved) {
        //...
        return {
            path: path$o.resolve(cleanUrl(resolved)),
            namespace,
        };
    }
});
build.onLoad({ filter: JS_TYPES_RE }, async ({ path: id }) => {
    let ext = path$o.extname(id).slice(1);
    let contents = await fsp.readFile(id, 'utf-8');
    const loader = config.optimizeDeps?.esbuildOptions?.loader?.[`.${ext}`] ||
        ext;
    return {
        loader,
        contents,
    };
});

onLoad()解析main.js的内容如下所示,本质也就是读取对应main.js本身的内容

return {
    loader: "js",
    content: `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");`
}
onLoad()返回content中包含了多个import语句,再度触发onResolve()的执行
.vue文件触发container.resolveId()

此时id="vue",会触发vite:resolve插件的resolveId()方法处理,最终触发了tryNodeResolve()的处理,由于代码非常繁多,精简过后的代码如下所示:

  • resolvePackageData(): 获取"vue"所在的package.json数据
  • resolvePackageEntry(): 根据package.json数据的字段去获取对应的入口
name:"vite:resolve"
async resolveId() {
    if (bareImportRE.test(id)) {
        if ((res = tryNodeResolve(id, importer, options, targetWeb, depsOptimizer, ssr, external))) {
            return res;
        }
    }
}
function tryNodeResolve(id, ...) {
    const { root, ... } = options;
    let basedir;

    //...省略一系列条件
    basedir = root;

    const pkg = resolvePackageData(pkgId, basedir, preserveSymlinks, packageCache);
    const resolveId = deepMatch ? resolveDeepImport : resolvePackageEntry;
    const unresolvedId = deepMatch ? '.' + id.slice(pkgId.length) : pkgId;
    let resolved = resolveId(unresolvedId, pkg, targetWeb, options);
    if (!options.ssrOptimizeCheck &&
        (!isInNodeModules(resolved) || // linked
            !depsOptimizer || // resolving before listening to the server
            options.scan) // initial esbuild scan phase
    ) {
        return { id: resolved };
    }
}
function resolvePackageData(pkgName, basedir, preserveSymlinks = false, packageCache) {
    while (basedir) {
        const pkg = path$o.join(basedir, 'node_modules', pkgName, 'package.json');
        if (fs$l.existsSync(pkg)) {
            const pkgPath = preserveSymlinks ? pkg : safeRealpathSync(pkg);
            const pkgData = loadPackageData(pkgPath);
            return pkgData;
        }
        const nextBasedir = path$o.dirname(basedir);
        if (nextBasedir === basedir)
            break;
        basedir = nextBasedir;
    }
    return null;
}

resolvePackageEntry()的代码逻辑非常繁多,遍历了package.json多个字段,找对应的入口,主要经历了:

  • exports
  • browser/module
  • field
  • main

最终找到对应的entry,跟dir进行合并,形成最终的路径,此时

  • dir="/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/node_modules/vue"
  • entry="./dist/vue.runtime.esm-bundler.js"
function resolvePackageEntry(id, { dir, data, setResolvedCache, getResolvedCache }, targetWeb, options) {
    let entryPoint;
    if (data.exports) {
        entryPoint = resolveExportsOrImports(data, '.', options, targetWeb, 'exports');
    }
    //...省略browser/module、field的逻辑判断
    entryPoint || (entryPoint = data.main);
    const entryPoints = entryPoint
        ? [entryPoint]
        : ['index.js', 'index.json', 'index.node'];
    for (let entry of entryPoints) {
        //...
        const entryPointPath = path$o.join(dir, entry);
        const resolvedEntryPoint = tryFsResolve(entryPointPath, options, true, true, skipPackageJson);
        if (resolvedEntryPoint) {
            return resolvedEntryPoint;
        }
    }
}
.vue文件触发onResolve

此时id="vue",经过resolve()后拼接成完整的路径resolved="xxx/vite-debugger/node_modules/vue/dist/vue.runtime.esm-bundler.js"

最终进行depImports["vue"]="xxx/vite-debugger/node_modules/vue/dist/vue.runtime.esm-bundler.js"

最终返回externalUnlessEntry({ path: id })={external: true,path: "vue"},由于externaltrue,因此不会触发onLoad()的执行

// bare imports: record and externalize ----------------------------------
build.onResolve({
    // avoid matching windows volume
    filter: /^[\w@][^:]/,
}, async ({ path: id, importer, pluginData }) => {
    const resolved = await resolve(id, importer, { ...});
    if (resolved) {
        if (isInNodeModules(resolved) || include?.includes(id)) {
            if (isOptimizable(resolved, config.optimizeDeps)) {
                depImports[id] = resolved;
            }
            return externalUnlessEntry({ path: id });
        }
        //...
    }
});
同理
"./App"的解析可以参考"main.js"的流程分析:先进行路径的整理,然后获取具体的source内容,再度触发解析
"lodash-es"的解析可以参考"vue"的流程分析:先进行路径的整理,然后存放在depImports中,最后结束流程
第三部分返回esbuild插件打包代码
esbuild.context()esbuild提供的API,相比较esbuild.build(),使用给定context完成的所有构建共享相同的构建选项,并且后续构建是增量完成的(即它们重用以前构建的一些工作以提高性能)

直接返回return await esbuild.context({}),本质就是返回一个Promise

async function prepareEsbuildScanner(...) {
    const container = await createPluginContainer(config);
    if (scanContext?.cancelled)
        return;
    const plugin = esbuildScanPlugin(...);
    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: [...plugins, plugin],
        ...esbuildOptions,
    });
}

3.5.3 开始打包

在上面esbuild插件打包: esbuild.context()的分析中,我们知道,esbuildContext本质就是await esbuild.context()

根据esbuild官方文档的描述,Rebuild模式允许您手动调用构建

在经历了3.5.13.5.2步骤之后,我们开始了context.rebuild的执行,也就是触发3.5.2步骤中的esbuild打包流程
打包过程中,得到所有node_moduels的依赖放入到deps对象中,打包完成后,将deps数据返回

function scanImports(config) {
    const deps = {};
    // 3.5.1 计算入口文件computeEntries
    const esbuildContext = computeEntries(config).then((computedEntries) => {
        entries = computedEntries;
        // 3.5.2 打包入口文件esbuild插件初始化
        return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
    });
    const result = esbuildContext
        .then((context) => {
            // 3.5.3 开始打包
            return context
                .rebuild()
                .then(() => {
                    return {
                        deps: orderedDependencies(deps),
                        missing,
                    };
                });
        })
    return { result, cancel }
}

3.6 依赖扫描后进行打包runOptimizeDeps()

3.5.2步骤的分析中,我们知道对于node_modules的打包,我们会存储到depImports["vue"]="xxx/vite-debugger/node_modules/vue/dist/vue.runtime.esm-bundler.js",然后scanImports()会返回存储的所有deps数据

function scanImports(config) {
    const deps = {};
    // 3.5.1 计算入口文件computeEntries
    const esbuildContext = computeEntries(config).then((computedEntries) => {
        entries = computedEntries;
        // 3.5.2 打包入口文件esbuild插件初始化
        return prepareEsbuildScanner(config, entries, deps, missing, scanContext);
    });
    const result = esbuildContext
        .then((context) => {
            // 3.5.3 开始打包
            return context
                .rebuild()
                .then(() => {
                    return {
                        deps: orderedDependencies(deps),
                        missing,
                    };
                });
        })
    return { result, cancel }
}

3.6步骤中,我们提取出所有获取到的deps数据,即discover.result,然后执行runOptimizeDeps()

async function createDepsOptimizer(config, server) {
    // 第一步:3.4获取缓存
    const cachedMetadata = await loadCachedDepOptimizationMetadata(config, ssr);
    if (!cachedMetadata) {
        // 第二步:3.5没有缓存时进行依赖扫描
        discover = discoverProjectDependencies(config);
        const deps = await discover.result;
        // 第三步:3.6依赖扫描后进行打包runOptimizeDeps(),存储到node_modules/.vite
        optimizationResult = runOptimizeDeps(config, knownDeps);
    }
}

runOptimizeDeps()的代码中,主要分为两个部分:

  • 获取xxx/node_modules/.vite/deps的完整路径depsCacheDir,然后初始化_metadata.json,往_metadata.json写入打包的缓存信息
  • prepareEsbuildOptimizerRun()执行esbuild打包预构建的库到.vite/deps文件夹下面
下面代码已经详细分析了_metadata.json每一个属性的写入逻辑,接下来将着重分析下prepareEsbuildOptimizerRun()的打包逻辑
function runOptimizeDeps() {
    // 得到缓存数据的路径,也就是.vite/deps文件夹的完整路径
    const depsCacheDir = getDepsCacheDir(resolvedConfig, ssr);
    //创建.vite/package.json文件
    fs$l.writeFileSync(path$o.resolve(processingCacheDir, 'package.json'), `{\n  "type": "module"\n}\n`);
    // 初始化_metadata.json
    const metadata = initDepsOptimizerMetadata(config, ssr);
    //写入_metadata.json的browserHash属性
    metadata.browserHash = getOptimizedBrowserHash(metadata.hash, depsFromOptimizedDepInfo(depsInfo));

    // esbuild打包初始化
    const preparedRun = prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext);
    const runResult = preparedRun.then(({ context, idToExports }) => {
        // 执行esbuild打包
        return context
            .rebuild()
            .then((result) => {
                //...写入_metadata.json的optimized属性
                //...写入_metadata.json的chunks属性
            });
    });
    return {
        async cancel() {
            optimizerContext.cancelled = true;
            const { context } = await preparedRun;
            await context?.cancel();
            cleanUp();
        },
        result: runResult,
    };
}
function initDepsOptimizerMetadata(config, ssr, timestamp) {
    const hash = getDepHash(config, ssr);
    return {
        hash,
        browserHash: getOptimizedBrowserHash(hash, {}, timestamp),
        optimized: {},
        chunks: {},
        discovered: {},
        depInfoList: [],
    };
}

3.6.1 具体打包node_modules库进行到.vite/deps的逻辑

主要是进行打包前的参数准备,其中有几个参数需要注意下:

  • entryPoints: 将node_modules的依赖库的src平铺成为数组的形式作为esbuild.context()打包的入口entryPoints
  • outdir: 将node_modeuls/.vite/deps文件夹作为输出的目录
  • bundle: 设置为true时,打包一个文件意味着将任何导入的依赖项内联到文件中。这个过程是递归的,因为依赖的依赖(等等)也将被内联。默认情况下,esbuild将不会打包输入的文件,也就是vue.js的所有import依赖都会打包到vue.js中,而不会使用import的形式引入其它依赖库
  • format: 打包文件输出格式为ES Module
format有三个可能的值:iifecjsesm
async function prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext) {
    for (const id in depsInfo) {
        const src = depsInfo[id].src;
        const exportsData = await (depsInfo[id].exportsData ??
            extractExportsData(src, config, ssr));
        flatIdDeps[flatId] = src;
        idToExports[id] = exportsData;
    }
    plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr));
    const context = await esbuild.context({        
        entryPoints: Object.keys(flatIdDeps),
        outdir: processingCacheDir,
        bundle: true,
        format: 'esm',
        plugins,
        ...
    });
    return { context, idToExports };
}
function runOptimizeDeps() {
    // esbuild打包初始化
    const preparedRun = prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext);
    const runResult = preparedRun.then(({ context, idToExports }) => {
        // 执行esbuild打包
        return context
            .rebuild()
            .then((result) => {
                //...写入_metadata.json的optimized属性
                //...写入_metadata.json的chunks属性
            });
    })
}

3.7 预构建目的实现原理分析

针对的是node_modules依赖的预构建,不包括实际的业务代码

3.7.1 CommonJS 和 UMD 兼容性

CommonJS代码如何改造,从而支持ESModule形式导入?
https://cn.vitejs.dev/guide/dep-pre-bundling.html官方文档中,使用React作为例子,我们直接使用React作为示例跑起来

截屏2023-04-15 14.32.44.png

vite预构建的react.js进行整理,形成下面的代码块:

var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = function (cb, mod) {
    return function __require() {
        return mod ||
            (0, cb[__getOwnPropNames(cb)[0]])
                ((mod = { exports: {} }).exports, mod), mod.exports;
    };
}
// node_modules/react/cjs/react.development.js
var require_react_development = __commonJS({
    "node_modules/react/cjs/react.development.js"(exports, module) {

        "use strict";
        if (true) {
            (function () {
                //react的common.js代码
                exports.xx = xxx;
                exports.xxxx = xxxxx;
            })();
        }
    }
});
// node_modules/react/index.js
var require_react = __commonJS({
    "node_modules/react/index.js": function (exports, module) {
        if (false) {
            module.exports = null;
        } else {
            module.exports = require_react_development();
        }
    }
});
export default require_react();

手动创建modmod.exports传入(exports, module)中,此时

  • mod=module
  • mod.exports=exports

CommonJs代码中,比如下面示例的cjs/react.development.js中,会使用传入的exports进行变量的赋值
最终输出export default module.exports,即export default {xxx, xxx, xxx, xxx}

从源码层级,是如何实现上面的转化流程?

截屏2023-04-15 15.11.47.png
从源码中,我们可以看到,最终在第二次esbuild打包中,使用format: 'esm',利用esbuild提供的能力将cjs格式转化为esm格式

async function prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext) {
    for (const id in depsInfo) {
        const src = depsInfo[id].src;
        const exportsData = await (depsInfo[id].exportsData ??
            extractExportsData(src, config, ssr));
        flatIdDeps[flatId] = src;
        idToExports[id] = exportsData;
    }
    plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr));
    const context = await esbuild.context({        
        entryPoints: Object.keys(flatIdDeps),
        outdir: processingCacheDir,
        bundle: true,
        format: 'esm',
        plugins,
        ...
    });
    return { context, idToExports };
}
function runOptimizeDeps() {
    // esbuild打包初始化
    const preparedRun = prepareEsbuildOptimizerRun(resolvedConfig, depsInfo, ssr, processingCacheDir, optimizerContext);
    const runResult = preparedRun.then(({ context, idToExports }) => {
        // 执行esbuild打包
        return context
            .rebuild()
            .then((result) => {
                //...写入_metadata.json的optimized属性
                //...写入_metadata.json的chunks属性
            });
    })
}
CommonJS->ESModule需要注意的点

node_moduels的依赖库如果本身是commonjs格式,会使用esbuild自动转化为esm格式,但是我们自己编写的业务代码,比如:
截屏2023-04-15 15.23.02.png

我们可以发现,虽然我们自己编写的业务代码是完全模仿react.js的导入模式重新书写了一遍,但是直接运行就是报错

截屏2023-04-15 15.21.09.png

经过上面的源码调试,其实我们已经能够猜到,之所以报错,就是没有转化indexB.jsesmodule格式

  • 在第一次esbuild打包时,会使用index.html作为esbuild打包的input,然后使用build.onResolve()build.onLoad()处理main.js等文件相关路径以及内容中包含的import路径,不断触发build.onResolve()build.onLoad(),递归处理所有涉及到的文件的路径,同时收集所有node_modulesdeps
  • 在第二次esbuild打包中,会使用所有node_modules的依赖入口文件作为esbuild打包的input,然后进行打包,此时所有commonjs的文件会被转化为esmodule以及内联到同一个文件中

截屏2023-04-15 17.26.15.png
从上面的分析中,我们可以发现,没有转化indexB.jsesmodule格式就是因为第二次esbuild打包没有加入indexB.js
那么如果我们强行加入indexB.js在第二次esbuild打包中,如下图所示,然后我们main.js直接就使用node_modules/.vite/deps/indexB.js

"react"会被自动转化为"node_modules/.vite/deps/react.js"路径

截屏2023-04-15 17.09.38.png
结果如我们预想中一样,正常运行,并且indexB.js也会转化为esmodule格式,同时它也被写入到node_modules/.vite/deps/indexB.js
截屏2023-04-15 17.11.24.png
从中我们就明白了一个事情,vite预构建针对的是node_modules的依赖,而实现CommonJS 和 UMD 兼容性目的本质借助的是esbuild的打包能力,借助它format:"esm"的输出格式转化commonjsesmodule格式

那如果我们硬要在业务代码中使用commonjsrequire语句?我们该如何做呢?
业务代码中使用CommonJS代码
下面分析内容大部分都参考https://github.com/evanw/esbuild/issues/506https://github.com/vitejs/vite/issues/3409

vite使用esbuild进行预构建,可以将commonjs转化为esmodule

commonjs转化为esmodule的原理在上面我们也分析过,就是在commonjs代码的基础上再注入一些代码,进行export default {xx, xx}

然而esbuild无法转化require语句,从esbuild/issues/506也可以看出,就算node_modules依赖库中有require语句也无法转化,外部业务代码的require语句更加无法转化,因为外部的业务代码都没经过第二次esbuild打包转化为esm格式
截屏2023-04-15 20.56.06.png
vite除了esbuild部分的代码并不支持require语句,只支持import语句
截屏2023-04-15 21.08.44.png
因此为了能够在vite的业务代码中使用commonjs,将业务代码的require语句转化为import语句,我们需要引入对应的plugin: vite-plugin-commonjs

import { viteCommonjs } from '@originjs/vite-plugin-commonjs'
export default {
    plugins: [
        viteCommonjs()
    ]
}

如果需要将node_modulesrequire语句转化为import语句,需要声明:

import { esbuildCommonjs } from '@originjs/vite-plugin-commonjs'
export default {
    optimizeDeps:{
    esbuildOptions:{
      plugins:[
        esbuildCommonjs(['react-calendar','react-date-picker']) 
      ]
    }
  }
}

3.7.2 性能

为了提高后续页面的加载性能,Vite将那些具有许多内部模块的 ESM 依赖项转换为单个模块。
有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。
通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!

esbuild打包时设置bundle:true,打包一个文件会将任何导入的依赖项内联到文件中

4. 请求资源middlewares

注意:调试app.use(xxxMiddleware(server))时需要打开浏览器,例如[http://127.0.0.1:5173/](http://127.0.0.1:5173/)触发请求发送,才能触发xxxMiddleware相关逻辑断点,才能调试本地服务器的相关代码

有多种功能的中间件,比如proxyMiddleware可以在本地开发环境中跨域请求,servePublicMiddleware处理public资源,serveStaticMiddlewareserveRawFsMiddleware处理静态文件

async function createServer(inlineConfig = {}) {
    const middlewares = connect();
    // proxy
    const { proxy } = serverConfig;
    if (proxy) {
        middlewares.use(proxyMiddleware(httpServer, proxy, config));
    }
    // serve static files under /public
    if (config.publicDir) {
        middlewares.use(servePublicMiddleware(config.publicDir, config.server.headers));
    }
    // main transform middleware
    middlewares.use(transformMiddleware(server));
    // serve static files
    middlewares.use(serveRawFsMiddleware(server));
    middlewares.use(serveStaticMiddleware(root, server));
}
这些中间件中,transformMiddleware的功能最为复杂和重要,本文将只针对transformMiddleware展开分析,其它中间件请参考其它文章

4.1 transform

在初始化createServer()中,我们会使用middlewares.use(transformMiddleware(server))进行文件内容的转化

async function createServer(inlineConfig = {}) {
    const middlewares = connect();   
    // main transform middleware
    middlewares.use(transformMiddleware(server));
}

4.1.1 transformMiddleware流程图

transformRequest-simple.svg

4.1.2 使用transformMiddleware的原因

  1. 对于esmodule,不支持import xx from "vue"这种导入的,需要转化为相对路径或者绝对路径的形式
  2. 浏览器只认识js,不支持其它后缀的文件名称,比如.vue.ts,需要进行处理

4.1.3 transformMiddleware整体流程(源码整体概述)

function transformMiddleware(server) {
    const { config: { root, logger }, moduleGraph, } = server;
    return async function viteTransformMiddleware(req, res, next) {
        if (req.method !== 'GET' || knownIgnoreList.has(req.url)) {
            return next();
        }
        let url = decodeURI(removeTimestampQuery(req.url)).replace(NULL_BYTE_PLACEHOLDER, '\0');
        //...处理 xxx.js.map的情况
        //...处理url是public/xx开头的情况,提示警告

        if (isJSRequest(url) ||
            isImportRequest(url) ||
            isCSSRequest(url) ||
            isHTMLProxy(url)) {
                
            if (isCSSRequest(url) &&
                !isDirectRequest(url) &&
                req.headers.accept?.includes('text/css')) {
                url = injectQuery(url, 'direct');
            }
            const result = await transformRequest(url, server, {
                html: req.headers.accept?.includes('text/html'),
            });
            if (result) {
                const depsOptimizer = getDepsOptimizer(server.config, false); // non-ssr
                const type = isDirectCSSRequest(url) ? 'css' : 'js';
                const isDep = DEP_VERSION_RE.test(url) || depsOptimizer?.isOptimizedDepUrl(url);
                return send$1(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,
                });
            }
        }
        next();
    };
}

4.1.4 transformRequest & doTransform

判断是否有缓存数据,即server.moduleGraph.getModuleByUrl().transformResult是否存在,如果存在,则直接返回
如果没有缓存数据,则调用pluginContainer.resolveId()->loadAndTransform()进行内容的转化

function transformRequest(url, server, options = {}) {
    const request = doTransform(url, server, options, timestamp);
    return request;
}
async function doTransform(url, server, options, timestamp) {
    const { config, pluginContainer } = server;
    const module = await server.moduleGraph.getModuleByUrl(url, ssr);
    // check if we have a fresh cache
    const cached = module && (ssr ? module.ssrTransformResult : module.transformResult);
    if (cached) {
        return cached;
    }
    // resolve
    const id = (await pluginContainer.resolveId(url, undefined, { ssr }))?.id || url;
    const result = loadAndTransform(id, url, server, options, timestamp);
    getDepsOptimizer(config, ssr)?.delayDepsOptimizerUntil(id, () => result);
    return result;
}

4.1.5 pluginContainer.resolveId

3.5.2步骤中分析过,getSortedPlugins('resolveId')就是检测初始化时注册的插件是否有resolveId这个属性,如果有,则添加到返回的数组集合中,比如有10个插件中有5个插件具有resolveId属性,那么最终getSortedPlugins('resolveId')拿到的就是这5个插件的Array数据

因此container.resolveId()中运行插件的个数不止一个,但并不是每一个插件都能返回对应的结果result,即const result = await handler.call(...)可能为undefined

当有插件处理后result不为undefined时,会直接执行break,然后返回container.resolveId()的结果

async resolveId(rawId, importer = join$2(root, 'index.html'), options) {
    for (const plugin of getSortedPlugins('resolveId')) {
        if (!plugin.resolveId)
            continue;
        if (skip?.has(plugin))
            continue;
        const handler = 'handler' in plugin.resolveId
            ? plugin.resolveId.handler
            : plugin.resolveId;
        const result = await handler.call(...);
        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$3(id);
        return partial;
    } else {
        return null;
    }
}

4.1.6 loadAndTransform

从下面精简后的代码可以知道,主要分为三个部分:

  • 第一部分pluginContainer.load: 读取文件内容
  • 第二部分moduleGraph.ensureEntryFromUrl: 创建moduleGraph缓存
  • 第三部分pluginContainer.transform: 转化文件内容
async function loadAndTransform(id, url, server, options, timestamp) {
    //...
    // 第一部分:读取文件内容
    const loadResult = await pluginContainer.load(id, { ssr });
    if (loadResult == null) {
        if (options.ssr || isFileServingAllowed(file, server)) {
            code = await promises$2.readFile(file, 'utf-8');
        }
        if (code) {
            map = (convertSourceMap.fromSource(code) ||
                (await convertSourceMap.fromMapFileSource(code, createConvertSourceMapReadMap(file))))?.toObject();
            code = code.replace(convertSourceMap.mapFileCommentRegex, blankReplacer);
        }
    } else {
        if (isObject$2(loadResult)) {
            code = loadResult.code;
            map = loadResult.map;
        } else {
            code = loadResult;
        }
    }

    // 第二部分:创建moduleGraph缓存,将下面transform结果存入mod中
    const mod = await moduleGraph.ensureEntryFromUrl(url, ssr);
    ensureWatchedFile(watcher, mod.file, root);
    // 第三部分:transform转化文件内容
    const transformStart = isDebug$3 ? performance$1.now() : 0;
    const transformResult = await pluginContainer.transform(code, id, {
        inMap: map,
        ssr,
    });

    //...简单处理下转化后的文件结果
    const originalCode = code;
    const result = ssr && !server.config.experimental.skipSsrTransform
        ? await server.ssrTransform(code, map, url, originalCode)
        : {
            code,
            map,
            etag: etag_1(code, { weak: true }),
        };
    if (timestamp > mod.lastInvalidationTimestamp) {
        if (ssr)
            mod.ssrTransformResult = result;
        else
            mod.transformResult = result;
    }
    return result;
}
第一部分读取文件内容:pluginContainer.load

pluginContainer.load()跟之前分析的pluginContainer.resolveId()类似,都是去遍历所有注册的插件,然后返回结果,找到满足条件的那个插件
如果没有插件符合题意,即loadResult==null时,会进行文件读取的方式获取该文件的内容

async function loadAndTransform(id, url, server, options, timestamp) {
    //...
    // 第一部分:读取文件内容
    const loadResult = await pluginContainer.load(id, { ssr });
    if (loadResult == null) {
        if (options.ssr || isFileServingAllowed(file, server)) {
            code = await promises$2.readFile(file, 'utf-8');
        }
    }
}
async load(id, options) {
    for (const plugin of getSortedPlugins('load')) {
        if (!plugin.load)
            continue;
        const handler = 'handler' in plugin.load ? plugin.load.handler : plugin.load;
        const result = await handler.call(ctx, id, { ssr });
        if (result != null) {
            if (isObject$2(result)) {
                updateModuleInfo(id, result);
            }
            return result;
        }
    }
    return null;
}
那什么情况下loadResult可以读取到?什么情况下读取不到呢?会触发什么类型的插件执行load()获取到loadResult呢?

为了更好地理解pluginContainer.load()的调用逻辑,我们使用示例Index.vue进行分析

<template>
  <div class="index-wrapper">这是Index.vue</div>
</template>
<script>
  export default {
    name: "Index",
    setup() {
      const indexData = "index Data";
      return {}
    }
  }
</script>
<style scoped>
  .index-wrapper {
    background-color: rebeccapurple;
  }
</style>

一些原始的文件,比如main.jsmain.cssIndex.vue则直接使用readFile进行内容的读取
截屏2023-04-07 01.54.53.png
而一些需要插件进行获取的文件数据,比如Index.vue文件经过transform流程解析<style>得到的数据、解析<script>得到的数据,都需要特定的插件进行处理获取数据

可以理解为,如果单纯读取文件内容,直接使用readFile()即可,如果还需要对内容进行加工或者改造,则需要走插件进行处理

截屏2023-04-07 01.43.35.png

第二部分初始化缓存:moduleGraph.ensureEntryFromUrl

简单的逻辑,根据url创建对应的new ModuleNode()缓存对象,等待pluginContainer.transform返回result后,将result存入到mod

async function loadAndTransform(id, url, server, options, timestamp) {
    //...
    // 第二部分:创建moduleGraph缓存,将下面transform结果存入mod中
    const mod = await moduleGraph.ensureEntryFromUrl(url, ssr);
    ensureWatchedFile(watcher, mod.file, root);
    // 第三部分:transform转化文件内容
    const transformStart = isDebug$3 ? performance$1.now() : 0;
    const transformResult = await pluginContainer.transform(code, id, {
        inMap: map,
        ssr,
    });
    if (timestamp > mod.lastInvalidationTimestamp) {
        if (ssr)
            mod.ssrTransformResult = result;
        else
            mod.transformResult = result;
    }
}
async ensureEntryFromUrl(rawUrl, ssr, setIsSelfAccepting = true) {
    const [url, resolvedId, meta] = await this.resolveUrl(rawUrl, ssr);
    let mod = this.idToModuleMap.get(resolvedId);
    if (!mod) {
        mod = new ModuleNode(url, setIsSelfAccepting);
        this.urlToModuleMap.set(url, mod);
        mod.id = resolvedId;
        this.idToModuleMap.set(resolvedId, mod);
        const file = (mod.file = cleanUrl(resolvedId));
        let fileMappedModules = this.fileToModulesMap.get(file);
        if (!fileMappedModules) {
            fileMappedModules = new Set();
            this.fileToModulesMap.set(file, fileMappedModules);
        }
        fileMappedModules.add(mod);
    } else if (!this.urlToModuleMap.has(url)) {
        this.urlToModuleMap.set(url, mod);
    }
    return mod;
}
第三部分转化文件内容:pluginContainer.transform

pluginContainer.transform()跟之前分析的pluginContainer.resolveId()类似,都是去遍历所有注册的插件,然后返回结果,而根据不同的文件类型,会调用不同类型的插件进行transform()处理,比如:

  • vite:css: css编译插件,下面4.3.6步骤解析.vue文件得到的<style>形成的语句最终会调用vite:css插件进行解析
  • vite:esbuild: .ts.jsx.tsx转化.js的插件,用来代替传统的tsc转化功能
async transform(code, id, options) {
    for (const plugin of getSortedPlugins('transform')) {
        if (!plugin.transform)
            continue;
        let result;
        const handler = 'handler' in plugin.transform
            ? plugin.transform.handler
            : plugin.transform; 
            result = await handler.call(ctx, code, id, { ssr });    
        if (!result)
            continue;
        //...将result赋值给code
    }
    return {
        code,
        map: ctx._getCombinedSourcemap(),
    };
}

4.1.7 小结

插件流程

在上面的transformMiddleware的分析流程中,我们涉及到多个插件的resolveId()load()transform()流程,这本质是一套规范的rollup插件流程

比如transform()流程,见下面分析内容

transform是rollup插件规定的Build Hooks,具体可以参考Rollup 插件文档
截屏2023-04-07 23.56.54.png
rollup插件整体的构建流程如下所示:
截屏2023-04-08 02.14.28.png

middleware处理流程跟预构建流程的差别
预构建也有路径resolveId()处理,middleware处理流程也有resolveId()路径处理,这两方面有什么差别?
预构建有获取内容,middleware处理流程也有获取内容,这两方面有什么差别呢?

预构建的路径resolveId(),是为了能够得到完整的路径,然后进行readFile()读取文件内容,最终根据内容找到依赖的其它文件,然后触发其它依赖文件执行相关的build.onResolve()build.onLoad(),从而遍历完所有的文件,进行预构建node_modules相关文件的依赖收集
最终收集完成输出deps数据,根据deps数据进行预构建:esbuild打包到node_modeuls/.vite/xxx文件中

middleware处理流程的resolveId()流程,涉及到node_modules相关路径的获取,会根据预构建得到的depsOptimizer拿到对应的路径数据,其它路径的获取则跟预构建流程一致,最终获取到绝对路径
然后触发对应的load()->transform()插件流程,这个时候不同类型的数据会根据不同的插件进行处理,比如.scss文件交由vite:css文件进行转化为css数据,.vue文件交由vite:vue进行单页面的解析成为多个部分进行数据的获取,最终形成浏览器可以识别的js数据内容,然后返回给浏览器进行执行和显示

4.1.5步骤中,我们简单分析了loadAndTransform()的整体流程,但是涉及到的一些插件没有具体展开分析,下面我们将使用具体的例子,将涉及到的插件简单进行分析

4.2 常见插件源码分析

4.2.1 vite:import-analysis分析

当浏览器请求main.js时,由4.3.5第一部分的分析中,我们知道pluginContainer.load()返回结果为空,会直接使用readFile()读取文件内容
然后触发pluginContainer.transform("main.js"),此时会触发插件vite:import-analysistransform()方法
如下面代码块所示,在这个方法中,我们会提取出所有import的数据,然后进行遍历,在遍历过程中

  • 使用normalizeUrl()去掉rootDir的前缀,调用pluginContainer.resolveId()进行路径的重写
  • 添加到staticImportedUrls,提前触发transformRequest()进行import文件的转化
name: 'vite:import-analysis',
async transform(source, importer, options) {
    let imports;
    let exports;
    [imports, exports] = parse$e(source);

    for (let index = 0; index < imports.length; index++) {
        const { s: start, e: end, ss: expStart, se: expEnd, d: dynamicIndex,
            n: specifier, a: assertIndex, } = imports[index];
        // resolvedId="/Users/wcbbcc/blog/Frontend-Articles/vite-debugger/node_modules/.vite/deps/vue.js?v=da0b3f8b"
        // url="/node_modules/.vite/deps/vue.js?v=da0b3f8b"
        const [url, resolvedId] = await normalizeUrl(specifier, start);
        if (!isDynamicImport) {
            // for pre-transforming
            staticImportedUrls.add({ url: hmrUrl, id: resolvedId });
        }
    }
    if (config.server.preTransformRequests && staticImportedUrls.size) {
        staticImportedUrls.forEach(({ url }) => {
            url = removeImportQuery(url);
            transformRequest(url, server, { ssr }).catch((e) => {
            });
        });
    }
}
const normalizeUrl = async (url, pos, forceSkipImportAnalysis = false) => {
  const resolved = await this.resolve(url, importerFile);
  if (resolved.id.startsWith(root + '/')) {
      url = resolved.id.slice(root.length);
  }
  //url="/node_modules/.vite/deps/vue.js?v=c1e0320d"
  return [url, resolved.id];
};
pluginContainer.resolveId()逻辑

跟上面预构建的流程相同,都是触发插件vite:resolve的执行,但是此时的depsOptimizer已经存在,因此会直接从depsOptimizer中获取对应的路径数据,返回路径node_modules/.vite/deps/xxx的数据

name: "vite:resolve"
async resolveId() {
    if (bareImportRE.test(id)) {
        const external = options.shouldExternalize?.(id);
        if (!external &&
            asSrc &&
            depsOptimizer &&
            !options.scan &&
            (res = await tryOptimizedResolve(depsOptimizer, id, importer))) {
            return res;
        }

        if ((res = tryNodeResolve(id, importer, options, targetWeb, depsOptimizer, ssr, external))) {
            return res;
        }
    }
}
vite:import-analysis小结
  1. vite:import-analysis插件重写了import语句的路径,比如import {createApp} from "vue"重写为import {createApp} from "/node_modules/.vite/deps/vue.js?v=da0b3f8b"
  2. 除了替换了文件内容code中那些导入模块import的路径,还提前触发这些路径的transformRequest()调用

4.2.2 vite:vue分析

借助@vitejs/plugin-vue独立的插件,可以进行.vue文件的解析

当浏览器请求普通结构的Index.vue时,会触发vite:vue方法的解析,然后触发transformMain()方法解析

name: 'vite:vue',
async transform(code, id, opt) {
    //...
    if (!query.vue) {
        return transformMain(
            code,
            filename,
            options,
            this,
            ssr,
            customElementFilter(filename)
        );
    } else {
        //...
    }
}

在这个插件中,会进行<script><template><style>三种标签的数据解析

其中stylesCode会解析得到"import 'xxxxx/vite-debugger/src/Index.vue?vue&type=style&index=0&scoped=3d84b2a7&lang.css' "
之后会触发插件"vite:css"进行transform()的转化

然后使用output.join("\n")拼成数据返回

async function transformMain(code, filename, options, pluginContext, ssr, asCustomElement) {
    const { code: scriptCode, map: scriptMap } = await genScriptCode(
        descriptor,
        options,
        pluginContext,
        ssr
    );
    const hasTemplateImport = descriptor.template && !isUseInlineTemplate(descriptor, !devServer);
    if (hasTemplateImport) {
        ({ code: templateCode, map: templateMap } = await genTemplateCode(
            descriptor,
            options,
            pluginContext,
            ssr
        ));
    }
    const stylesCode = await genStyleCode(
        descriptor,
        pluginContext,
        asCustomElement,
        attachedProps
    );
    const output = [
        scriptCode,
        templateCode,
        stylesCode,
        customBlocksCode
    ];
    if (!attachedProps.length) {
        output.push(`export default _sfc_main`);
    } else {
        output.push(
            `import _export_sfc from '${EXPORT_HELPER_ID}'`,
            `export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps.map(([key, val]) => `['${key}',${val}]`).join(",")}])`
        );
    }
    let resolvedCode = output.join("\n");
    return {
        code: resolvedCode
    };
}


Index.vue的代码如下所示:

<template>
  <div class="index-wrapper">这是Index.vue</div>
</template>
<script>
  export default {
    name: "Index",
    setup() {
      const indexData = "index Data";
      return {}
    }
  }
</script>
<style scoped>
  .index-wrapper {
    background-color: rebeccapurple;
  }
</style>

Index.vue经过vite:vuetransform()转化后的output如下图所示,一共分为4个部分:

  • <template>: 转化为createElement()编译后的语句
  • <script>: export default转化为const _sfc_main=语句
  • <style>: 转化为import "xxx.vue?vue&lang.css"的语句
  • 其它代码: 热更新代码和其它运行时代码

截屏2023-04-07 14.39.31.png

5. 热更新HMR

由于篇幅原因,接下来的分析请看下一篇文章「vite4源码」dev模式整体流程浅析(二)

参考文章

  1. Vite源码分析,是时候弄清楚Vite的原理了
  2. Vite原理及源码解析
  3. vite2 源码分析(一) — 启动 vite
  4. Vite原理分析

工程化文章

  1. 「Webpack5源码」热更新HRM流程浅析
  2. 「Webpack5源码」make阶段(流程图)分析
  3. 「Webpack5源码」enhanced-resolve路径解析库源码分析
  4. 「vite4源码」dev模式整体流程浅析(一)
  5. 「vite4源码」dev模式整体流程浅析(二)

白边
209 声望37 粉丝

源码爱好者,已经完成vue2和vue3的源码解析+webpack5整体流程源码+vite4开发环境核心流程源码+koa2源码