乐趣区

关于前端:vite4源码dev模式整体流程浅析一

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

文章内容

  1. vite本地服务器的创立流程剖析
  2. vite预构建流程剖析
  3. vitemiddlewares 拦挡申请资源剖析
  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 文件

解决用户的输出后,调用 ./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 思维导图

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 预构建整体流程(流程图)

接下来会依据流程图的外围流程进行源码剖析

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

罕用办法 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 作为示例跑起来

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}

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

从源码中,咱们能够看到,最终在第二次 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 格局,然而咱们本人编写的业务代码,比方:

咱们能够发现,尽管咱们本人编写的业务代码是齐全模拟 react.js 的导入模式从新书写了一遍,然而间接运行就是报错

通过下面的源码调试,其实咱们曾经可能猜到,之所以报错,就是没有转化 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 以及内联到同一个文件中

从下面的剖析中,咱们能够发现,没有转化 indexB.jsesmodule格局就是因为第二次 esbuild 打包没有退出 indexB.js
那么如果咱们强行退出 indexB.js 在第二次 esbuild 打包中,如下图所示,而后咱们 main.js 间接就应用node_modules/.vite/deps/indexB.js

"react"会被主动转化为 "node_modules/.vite/deps/react.js" 门路

后果如咱们料想中一样,失常运行,并且 indexB.js 也会转化为 esmodule 格局,同时它也被写入到 node_modules/.vite/deps/indexB.js

从中咱们就明确了一个事件,vite预构建针对的是 node_modules 的依赖,而实现 CommonJS 和 UMD 兼容性 目标实质借助的是 esbuild 的打包能力,借助它 format:"esm" 的输入格局转化 commonjsesmodule格局

那如果咱们硬要在业务代码中应用 commonjsrequire 语句?咱们该如何做呢?

业务代码中应用 CommonJS 代码

上面剖析内容大部分都参考 https://github.com/evanw/esbuild/issues/506 和 https://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 格局

vite 除了 esbuild 局部的代码并不反对 require 语句,只反对 import 语句

因而为了可能在 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 流程图

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 进行内容的读取

而一些须要插件进行获取的文件数据,比方 Index.vue 文件通过 transform 流程解析 <style> 失去的数据、解析 <script> 失去的数据,都须要特定的插件进行解决获取数据

能够了解为,如果单纯读取文件内容,间接应用 readFile() 即可,如果还须要对内容进行加工或者革新,则须要走插件进行解决

第二局部初始化缓存: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 插件文档

rollup 插件整体的构建流程如下所示:

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" 的语句
  • 其它代码: 热更新代码和其它运行时代码

参考文章

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