本文基于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 nodeconst 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 responsesvar compression = require('compression');app.use(compression());// respond to all requestsapp.use(function(req, res){  res.end('Hello from Connect!\n');});//create node.js http server and listen on porthttp.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.jsconst { 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 =======startfunction 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 =======endfunction 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最终调用的就是getSortedPluginsByHookfunction 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.jsvar 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.jsvar 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原理剖析