Vite 源码(八)Vite 的预构建原理

2,742 阅读9分钟

先看下官网介绍

当你首次启动 vite 时,你可能会注意到打印出了以下信息:

Optimizable dependencies detected: (侦测到可优化的依赖:)
react, react-dom
Pre-bundling them to speed up dev server page load...(将预构建它们以提升开发服务器页面加载速度)
(this will be run only when your dependencies have changed)(这将只会在你的依赖发生变化时执行)

预构建作用

CommonJS 和 UMD 兼容性

开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。

当转换 CommonJS 依赖时,Vite 会执行智能导入分析,这样即使导出是动态分配的(如 React),按名导入也会符合预期效果:

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

性能

Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能;减少网络请求。

缓存

文件系统缓存

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

  • package.json 中的 dependencies 列表
  • 包管理器的 lockfile,例如 package-lock.jsonyarn.lock,或者 pnpm-lock.yaml
  • 可能在 vite.config.js 相关字段中配置过的

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

如果出于某些原因,你想要强制 Vite 重新构建依赖,你可以用 --force 命令行选项启动开发服务器,或者手动删除 node_modules/.vite 目录。

浏览器缓存

解析后的依赖请求会以 HTTP 头 max-age=31536000,immutable 强缓存,以提高在开发时的页面重载性能。一旦被缓存,这些请求将永远不会再到达开发服务器。如果安装了不同的版本(这反映在包管理器的 lockfile 中),则附加的版本 query(v=xxx) 会自动使它们失效

接下来来看源码是怎么实现上述功能的

源码

本地服务器启动时,会进行预构建

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

await server.listen()

通过createServer创建server对象后,调用server.listen方法启动服务器。启动后会执行httpServer.listen方法

在执行createServer时,会重写server.listen方法

let isOptimized = false
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
    if (!isOptimized) {
        try {
            // 调用 所有插件的 buildStart 钩子函数
            await container.buildStart({})
            await runOptimize()
            isOptimized = true
        } catch (e) {
            httpServer.emit('error', e)
            return
        }
    }
    return listen(port, ...args)
}) as any

首先调用所有插件的buildStart方法,然后调用runOptimize方法

const runOptimize = async () => {
    // 获取缓存路径,默认是 node_modules/.vite
    if (config.cacheDir) {
        // 表示当前正在预构建
        server._isRunningOptimizer = true
        try {
            server._optimizeDepsMetadata = await optimizeDeps(config)
        } finally {
            server._isRunningOptimizer = false
        }
        server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
}

上述代码先调用optimizeDeps方法,然后调用createMissingImporterRegisterFn方法。

先看下optimizeDeps方法,这个方法比较大,这里我们分步来看他做了啥

export async function optimizeDeps(
    config: ResolvedConfig,
    force = config.server.force, // 设置为 true 强制使依赖预构建
    asCommand = false,
    newDeps?: Record<string, string>, // missing imports encountered after server has started
    ssr?: boolean
): Promise<DepOptimizationMetadata | null> {
    // 重新赋值 config
    config = {
        ...config,
        command: 'build',
    }
    const { root, logger, cacheDir } = config

    // 拼接 _metadata.json 文件的路径(一般在 node_modules/.vite/_metadata.json)
    const dataPath = path.join(cacheDir, '_metadata.json')
    // 根据包管理器的 lockfile、vite.config.js 相关字段生成 hash 值
    // 官网说还会根据 package.json 中的 dependencies 列表,但是现在这个版本没有这样,可能是后续版本更新了
    const mainHash = getDepHash(root, config)
    const data: DepOptimizationMetadata = {
        hash: mainHash,
        browserHash: mainHash,
        optimized: {},
    }
    // ...

首先拼接 _metadata.json 文件的路径,一般在node_modules/.vite/_metadata.json。然后通过getDepHash生成 hash 值。并创建一个data对象

_metadata.json文件的作用是存储了预构建模块的一些信息,后续会详细介绍

if (!force) {
    let prevData: DepOptimizationMetadata | undefined
    try {
        // 获取 cacheDir 中 _metadata.json 文件的内容
        prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
    } catch (e) {}
    // 如果 _metadata.json 有内容,并且之前的 哈希值和现在刚生成的哈希值相同
    // 则表示没有依赖项发生改变,直接返回现在的 _metadata.json 的内容
    if (prevData && prevData.hash === data.hash) {
        log('Hash is consistent. Skipping. Use --force to override.')
        return prevData
    }
}

如果没有设置force(强制使依赖预构建),从_metadata.json中读取上次文件的内容,并判断hash值是否和现在的相同,如果相同,则直接返回_metadata.json中的内容。

接下来的逻辑就是如果依赖发生改变,或者没有预构建过;根据cacheDir创建空文件夹,这里就是.vite文件夹。然后在文件夹中创建package.json文件,并写入"type": "module"

// 如果有 cacheDir(默认.vite),清空缓存文件夹
// 如果没有,创建一个空文件
if (fs.existsSync(cacheDir)) {
    emptyDir(cacheDir)
} else {
    // 如果 recursive 为 true 返回创建的第一个目录路径,反之返回 undefined
    fs.mkdirSync(cacheDir, { recursive: true })
}
// cacheDir 中创建 package.json,并写入 "type": "module"
// 作用:给 Node 提示,缓存目录中的所有文件都应该被识别为 ES 模块
writeFile(
    path.resolve(cacheDir, 'package.json'),
    JSON.stringify({ type: 'module' })
)

小结

在这里先总结下上面的流程

  • 根据包管理器的 lockfile、vite.config.js 相关字段生成 hash 值
  • 获取_metadata.json文件的路径,里面的内容是上次预构建模块的信息
  • 如果不是强制预构建,比对_metadata.json文件中的 hash 和新创建的 hash 值
    • 如果一致直接返回_metadata.json中的内容
    • 如果不一致,创建/清空缓存文件夹(默认是.vite);缓存文件内创建package.json文件,并写入 "type": "module"

继续向下,根据传入的newDeps判断有没有依赖列表。如果没有,通过scanImports方法收集列表

let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
    ;({ deps, missing } = await scanImports(config))
} else {
    deps = newDeps
    missing = {}
}

自动依赖搜寻

scanImports方法定义如下,也是一步一步的看

export async function scanImports(config: ResolvedConfig): Promise<{
    deps: Record<string, string>
    missing: Record<string, string>
}> {
    const start = performance.now()

    let entries: string[] = []
    // 一般都是用默认的 index.html
    // 默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项。如果指定了 build.rollupOptions.input,Vite 将转而去抓取这些入口点。
    // 如果这两者都不适合你的需要,则可以使用此选项指定自定义条目,或者是相对于 vite 项目根的模式数组。这将覆盖掉默认条目推断。
    const explicitEntryPatterns = config.optimizeDeps.entries
    const buildInput = config.build.rollupOptions?.input

    if (explicitEntryPatterns) {
        // 如果配置了 config.optimizeDeps.entries
        // 在 config.root 下查找 explicitEntryPatterns 中对应的文件,并返回绝对路径
        entries = await globEntries(explicitEntryPatterns, config)
    } else if (buildInput) {
        // 如果配置了 build.rollupOptions.input
        const resolvePath = (p: string) => path.resolve(config.root, p)
        // 下面的逻辑都是将 buildInput 的路径修改为相对于 config.root 的路径
        if (typeof buildInput === 'string') {
            entries = [resolvePath(buildInput)]
        } else if (Array.isArray(buildInput)) {
            entries = buildInput.map(resolvePath)
        } else if (isObject(buildInput)) {
            entries = Object.values(buildInput).map(resolvePath)
        } else {
            throw new Error('invalid rollupOptions.input value.')
        }
    } else {
        // 查找 html 文件
        entries = await globEntries('**/*.html', config)
    }

    // 不支持的入口文件类型和虚拟文件不应该扫描依赖项。
    // 过滤非 .jsx .tsx .mjs .html .vue .svelte .astro 文件,并且文件必须存在
    entries = entries.filter(
        (entry) =>
            (JS_TYPES_RE.test(entry) || htmlTypesRE.test(entry)) &&
            fs.existsSync(entry)
    )

首先查找入口文件

  • 如果有config.optimizeDeps.entries配置项,则入口文件从这里查找
  • 如果有config.build.rollupOptions.input配置项,则入口文件从这里查找
  • 上述两种都没有,在项目跟目录下查找html文件
const deps: Record<string, string> = {}
const missing: Record<string, string> = {}
// 创建插件容器
const container = await createPluginContainer(config)
// 创建 esbuildScanPlugin 插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
// 获取 预构建的 plugins 和 配置项
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}

接下来就是定义查找预构建模块需要的变量,比如插件容器containeresbuildScanPlugin插件、config.optimizeDeps.esbuildOptions 中定义的plugins和其他 ESbuild 配置项

// 打包每个入口文件,并将引入的 js 合并到一起
await Promise.all(
entries.map((entry) =>
  build({
    absWorkingDir: process.cwd(),
    write: false,
    entryPoints: [entry],
    bundle: true,
    format: 'esm',
    logLevel: 'error',
    plugins: [...plugins, plugin],
    ...esbuildOptions
  })
)
)

return {
deps,
missing
}

接着就是调用 ESbuild 从入口模块开始构建整个项目,获取需要预构建的模块,并将依赖列表返回。其中会执行esbuildScanPlugin插件,这个插件就是查找的核心,看下这个插件的实现

const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

function esbuildScanPlugin(
    config: ResolvedConfig,
    container: PluginContainer,
    depImports: Record<string, string>,
    missing: Record<string, string>,
    entries: string[]
): Plugin {
    const seen = new Map<string, string | undefined>()

     // 通过 container.resolveId 处理 id,最终返回该模块的绝对路径
     // 并将这个模块路径添加到 seen 中,key 是 id + importer的上级目录,value是模块绝对路径
    const resolve = async (id: string, importer?: string) => {}

    const include = config.optimizeDeps?.include
    // 忽略的包
    const exclude = [
        ...(config.optimizeDeps?.exclude || []),
        '@vite/client',
        '@vite/env',
    ]
    // 设置 build.onResolve 钩子函数的返回值
    const externalUnlessEntry = ({ path }: { path: string }) => ({
        path, // 模块路径
        // 如果 entries 包含当前id,返回false
        // 如果 external 为 true,不会将当前模块打包到 bundle 中
        // 这段代码的意思是,如果当前模块包含在 entries 中,将这个模块打包到 bundle 中
        external: !entries.includes(path),
    })

    return {
        name: 'vite:dep-scan',
        setup(build) {},
    }
}

esbuildScanPlugin方法返回一个插件对象;定义了一个查找路径的resolve函数,和获取配置项中需要预构建的列表和忽略列表

这个插件会针对不同类型的文件做不同处理

外部文件、data:开头的文件、css、json、不知名文件

setup(build) {
    // 如果是 http(s) 的外部文件,不打包到 bundle 中
    build.onResolve({ filter: externalRE }, ({ path }) => ({
        path,
        external: true,
    }))
    // 如果是以 data: 开头,不打包到 bundle 中
    build.onResolve({ filter: dataUrlRE }, ({ path }) => ({
        path,
        external: true,
    }))
    // css & json
    build.onResolve(
        {filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json)$/},
        externalUnlessEntry
    )

    // known asset types
    build.onResolve(
        {filter: new RegExp(`\\.(${KNOWN_ASSET_TYPES.join('|')})$`)},
        externalUnlessEntry
    )

    // known vite query types: ?worker, ?raw
    build.onResolve({ filter: SPECIAL_QUERY_RE }, ({ path }) => ({
        path,
        external: true, // 不注入 boundle 中
    }))
}

第三方库

上面这些比较简单,这里就不多介绍了,接下来看下对于第三方依赖是怎么处理的

build.onResolve({ filter: /^[\w@][^:]/ }, async ({ path: id, importer }) => {
    // 判断引入的第三方模块是不是包含在 exclude 中
    if (exclude?.some((e) => e === id || id.startsWith(e + '/'))) {
        return externalUnlessEntry({ path: id })
    }
    // 如果当前模块已经被收集
    if (depImports[id]) {
        return externalUnlessEntry({ path: id })
    }
    // 获取 第三方模块的绝对路径
    const resolved = await resolve(id, importer)
    if (resolved) {
        // 虚拟路径、非绝对路径、非 .jsx .tsx .mjs .html .vue .svelte .astro 文件返回 true
        if (shouldExternalizeDep(resolved, id)) {
            return externalUnlessEntry({ path: id })
        }
        // 重点!!!! 这里进行收集第三方依赖
        // 如果路径包含 node_modules 子字符串,或者该文件在 include 中存在
        if (resolved.includes('node_modules') || include?.includes(id)) {
            // OPTIMIZABLE_ENTRY_RE = /\\.(?:m?js|ts)$/
            if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
                // 添加到 depImports 中
                depImports[id] = resolved
            }
            // 如果当前id,比如 vue,没有包含在entries时不将此文件打包到 bundle 中,反之打包
            return externalUnlessEntry({ path: id })
        } else {
            const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
            // linked package, keep crawling
            return {
                path: path.resolve(resolved),
                namespace,
            }
        }
    } else {
        // 说明没找到 id 对应的模块
        missing[id] = normalizePath(importer)
    }
})

如果模块导入路径以字母、数字、下划线、汉字、@开头,会被这个钩子函数捕获;获取模块绝对路径;如果模块路径包含node_modules子字符串,或者该模块在include中存在,并且是mjsjsts文件,则将这个模块添加到depImports

如果根据传入的id没有解析到模块路径,添加到missing

HTML、Vue 文件

对于 HTML、Vue文件,设置namespacehtml

// htmlTypesRE = /\.(html|vue|svelte|astro)$/
// importer:绝对路径,该文件在哪个文件里被导入的
// 设置路径,并设置 namespace 为 html
build.onResolve(
    { filter: htmlTypesRE },
    async ({ path, importer }) => {
        return {
            path: await resolve(path, importer)
            namespace: 'html',
        }
    }
)

当执行build.onLoad钩子函数时,namespacehtml会命中一个onload钩子函数

build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => {
    // 读取html、Vue内容
    let raw = fs.readFileSync(path, 'utf-8')
    // 将注释内容替换成 <!---->
    raw = raw.replace(commentRE, '<!---->')
    // 如果是 .html 结尾,则为true
    const isHtml = path.endsWith('.html')
    // 如果是 .html 结尾,则 regex 匹配 type 为 module 的 script 标签,反之,比如 Vue 匹配没有 type 属性的 script 标签
    // scriptModuleRE[1]: <script type="module"> 开始标签
    // scriptModuleRE[2]、scriptRE[1]: script 标签的内容
    // scriptRE[1]: <script> 开始标签
    const regex = isHtml ? scriptModuleRE : scriptRE
    // 重置 regex.lastIndex
    regex.lastIndex = 0
    let js = ''
    let loader: Loader = 'js'
    let match: RegExpExecArray | null
    while ((match = regex.exec(raw))) {
        const [, openTag, content] = match
        // 获取开始标签上的 src 内容
        const srcMatch = openTag.match(srcRE)
        // 获取开始标签上的 type 内容
        const typeMatch = openTag.match(typeRE)
        // 获取开始标签上的 lang 内容
        const langMatch = openTag.match(langRE)
        const type = typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
        const lang = langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
        // skip type="application/ld+json" and other non-JS types
        if ( type && !(type.includes('javascript') || type.includes('ecmascript') || type === 'module')) {
            continue
        }
        // 不同文件设置不同 loader
        if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
            loader = lang
        }
        // 将 src 或者 script 代码块内容添加到 js 字符串中
        if (srcMatch) {
            const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
            js += `import ${JSON.stringify(src)}\n`
        } else if (content.trim()) {
            js += content + '\n'
        }
    }
    // 清空多行注释和单行注释内容
    const code = js.replace(multilineCommentsRE, '/* */').replace(singlelineCommentsRE, '')
    // 处理 ts + <script setup 形式的 Vue 文件
    if (loader.startsWith('ts') && (path.endsWith('.svelte') || (path.endsWith('.vue') && /<script\s+setup/.test(raw))) ) {
        // 对于 ts + <script setup 形式的 Vue 文件,导入的内容会被 ESbuild 原样输出,这将阻止 ESbuild 进一步爬行
        // 解决方案是为每个导入添加' import 'x' ',以强制 ESbuild 保持爬行
        while ((m = importsRE.exec(code)) != null) {
            if (m.index === importsRE.lastIndex) {
                importsRE.lastIndex++
            }
            js += `\nimport ${m[1]}`
        }
    }
    // ...

    return {
        loader,
        contents: js,
    }
})

这个钩子函数的主要作用就是将所有导入拼成一个字符串,并设置loader属性值;然后交给 ESbuild 处理,这样做的目的就是 ESbuild 会加载这些导入内容,并收集符合条件的模块。

HTML文件: 获取 HTML 中所有script标签,对于有src属性的,通过import()的形式拼接成字符串;对于内联的script标签,将内联代码拼接到字符串中,因为这里面可能包含导入代码,最后返回loadercontent

Vue文件:和 HTML 文件大致相同,都是获取导入内容并返回loadercontent

jsx、tsx、mjs

这几个比较简单,也是通过onload钩子函数,判断有没有配置jsxInject选项,如果有将这个选项内容添加到代码中

build.onLoad({ filter: JS_TYPES_RE }, ({ path: id }) => {
    let ext = path.extname(id).slice(1)
    if (ext === 'mjs') ext = 'js'

    let contents = fs.readFileSync(id, 'utf-8')
    // 如果是 tsx、jsx并且配置了 jsxInject,则将 jsxInject 注入到代码中
    if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) {
        contents = config.esbuild.jsxInject + `\n` + contents
    }
    return {
        loader: ext as Loader,
        contents,
    }
})

esbuildScanPlugin插件小结

esbuildScanPlugin插件的作用就是收集要进行预构建的模块。Vite 将从入口文件开始(默认是index.html)通过 ESbuild 抓取源码,并自动寻找引入的依赖项(即 "bare import",表示期望从 node_modules 解析)

回到scanImports方法中,通过 ESbuild 编译源码并收集需要预构建的第三方模块。最后返回deps(收集的模块)和missing(未找到的模块)

  // 打包每个入口文件,并将引入的 js 合并到一起
  await Promise.all(
    entries.map((entry) =>
      build({/* ... */})
    )
  )
  return {
    deps,
    missing
  }

depsmissing的数据结构如下

deps: {
	导入模块名/路径: 经解析后的绝对路径
}
missing: {
	导入模块名/路径: 导入该模块模块的路径
}

回到optimizeDeps中,调用scanImports方法之后,获取到丢失的模块和需要预构建的模块列表。

let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
  ;({ deps, missing } = await scanImports(config))
} else {
  deps = newDeps
  missing = {}
}

继续向下

// update browser hash
// 这个属性就是预构建模块的url上的 v 参数
data.browserHash = createHash('sha256')
    .update(data.hash + JSON.stringify(deps))
    .digest('hex')
    .substr(0, 8)
    
const missingIds = Object.keys(missing)
if (missingIds.length) {
    throw new Error(/* ... */)
}
// 收集 include 中剩余需要预构建的模块
// 将 config.optimizeDeps?.include 中的文件写入 deps 中
const include = config.optimizeDeps?.include
if (include) {/* ... */}

const qualifiedIds = Object.keys(deps)
// 如果 deps 没有,写入 _metadata.json 中
if (!qualifiedIds.length) {
    writeFile(dataPath, JSON.stringify(data, null, 2))
    log(`No dependencies to bundle. Skipping.\n\n\n`)
    return data
}

更新browserHash,如果有丢失模块,就抛出异常。反之将config.optimizeDeps.include中的剩余需要预构建的模块写入deps中。判断有没有需要预构建的依赖,如果没有将data写入_metadata.json中。

到此,收集过程结束,接下来进入编译过程

依赖收集小结

通过 ESbuild从入口文件开始(index.html)编译, 收集第三方依赖。如果是HTML、Vue文件会触发build.onload钩子函数,函数内获取script标签;对于有src属性的,通过import()的形式拼接成字符串;对于内联的script标签,将内联代码拼接成字符串;最后返回这个字符串;这样就可以获取HTML、Vue文件的导入内容。编译完成后,将includes中剩余需要预构建的模块添加到预构建列表中。到此预构建收集阶段结束,进入编译过程。

编译过程

获取 Esbuild 配置项、初始化变量

先是定义一堆变量,并获取 Esbuild 配置项

// 需要预构建的模块数量
const total = qualifiedIds.length
const maxListed = 5
const listed = Math.min(total, maxListed)
const flatIdDeps: Record<string, string> = {}
const idToExports: Record<string, ExportsData> = {}
const flatIdToExports: Record<string, ExportsData> = {}

const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
// 初始化 es-module-lexer
await init

收集

然后遍历所有需要预构建模块列表

for (const id in deps) {
    // 将 > 替换成 __,将 \ 和 . 替换成 _
    const flatId = flattenId(id)
    const filePath = (flatIdDeps[flatId] = deps[id])
    // 读取需要预构建模块的代码
    const entryContent = fs.readFileSync(filePath, 'utf-8')
    let exportsData: ExportsData
    try {
        // 通过 es-module-lexer 获取导入和导出位置
        exportsData = parse(entryContent) as ExportsData
    } catch {/* ... */}
    for (const { ss, se } of exportsData[0]) {
        // 获取导入内容
        // 比如 exp = import { initCustomFormatter, warn } from '@vue/runtime-dom'
        const exp = entryContent.slice(ss, se)
        if (/export\s+\*\s+from/.test(exp)) {
            // 如果 exp 是 export * from xxx 的形式,则设置 hasReExports 为 true
            exportsData.hasReExports = true
        }
    }
    idToExports[id] = exportsData
    flatIdToExports[flatId] = exportsData
}

遍历所有需要预构建模块列表,将对应模块的绝对路径添加到flatIdDeps中;读取模块代码,通过es-module-lexer将模块转换成AST,并赋值给exportsData。查找有没有export * from xxx形式的代码,如果有exportsData.hasReExports设置成true。最后将AST赋值给idToExportsflatIdToExports

flatIdToExportsidToExportsflatIdDeps结构如下

# id: 导入的模块或导入的路径
# flatId: 将 > 替换成 __,将 \ 和 . 替换成 _ 的导入路径/模块
flatIdDeps: {
    flatId: 对应模块的绝对路径
}
idToExports: {
    id: 对应模块的AST,是一个数组
}
flatIdToExports: {
    flatId: 对应模块的AST,是一个数组
}

开始构建

继续往下,开始通过 ESbuild 构建模块

// 构建过程中要替换的字符串
const define: Record<string, string> = {
    'process.env.NODE_ENV': JSON.stringify(config.mode),
}
// 设置 esbuild.define 的内容,用于替换编译后的内容
for (const key in config.define) {
    const value = config.define[key]
    define[key] = typeof value === 'string' ? value : JSON.stringify(value)
}

// 打包 deps 中的文件
const result = await build({
    absWorkingDir: process.cwd(),
    entryPoints: Object.keys(flatIdDeps),
    bundle: true, // 这里为 true,可以将有许多内部模块的 ESM 依赖关系转换为单个模块
    format: 'esm',
    target: config.build.target || undefined,
    external: config.optimizeDeps?.exclude,
    logLevel: 'error',
    splitting: true,
    sourcemap: true,
    outdir: cacheDir,
    ignoreAnnotations: true,
    metafile: true,
    define,
    plugins: [
        ...plugins,
        esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr), // 注意这里
    ],
    ...esbuildOptions,
})

通过 ESbuild 编译所有需要预编译的模块,入口文件就是这些需要预编译的模块。这里面使用了一个自定义插件esbuildDepPlugin,后面会分析,继续向下

生成预建模块的信息

// 获取打包到一起的文件生成的依赖图
const meta = result.metafile!

// 获取 cacheDir 相对于工作目录的路径
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
// 拼接 data 数据,并将 data 数据写入到 _metadata.json 中
for (const id in deps) {
  const entry = deps[id]
  data.optimized[id] = {
    file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
    src: entry,
    needsInterop: needsInterop( // needsInterop 作用是判断这个模块是不是 CommonJS 模块
      id,
      idToExports[id],
      meta.outputs,
      cacheDirOutputPath
    )
  }
}
writeFile(dataPath, JSON.stringify(data, null, 2))
// 返回 data
return data

先获取依赖图,然后拼接data对象,并写入到_metadata.json中,最后返回`data

  • 对于 CommonJS 模块,如果 ESbuild 设置了format: 'esm',会导致将它包装为ESM。就像下面这样
// a.ts
module.exports = {
    test: 1
}
// 编译后
import { __commonJS } from "./chunk-Z47AEMLX.js";

// src/a.ts
var require_a = __commonJS({
  "src/a.ts"(exports, module) {
    module.exports = { test: 1 };
  }
});

// dep:__src_a_ts
var src_a_ts_default = require_a();
export {
  src_a_ts_default as default
};
//# sourceMappingURL=__src_a_ts.js.map
  • es-module-lexer转换 CommonJS 模块,转换后的内容,导出和引入都是空数组

_metadata.json介绍

假设项目中只引入了 Vue,生成的_metadata.json如下

{
  "hash": "861d0c42",
  "browserHash": "c30d2c95",
  "optimized": {
    "vue": {
      "file": "/xxx/node_modules/.vite/vue.js", // 预构建生成的地址
      "src": "/xxx/node_modules/vue/dist/vue.runtime.esm-bundler.js", // 源码地址
      "needsInterop": false // 是否是 CommonJS 模块转成的 ESM 模块
    }
  }
}

esbuildDepPlugin

esbuildDepPlugin插件定义如下

export function esbuildDepPlugin(
    qualified: Record<string, string>,
    exportsData: Record<string, ExportsData>,
    config: ResolvedConfig,
    ssr?: boolean
): Plugin {
    // 创建 ESM 的路径查找函数
    const _resolve = config.createResolver({ asSrc: false })
    // 创建 CommonJS 的路径查找函数
    const _resolveRequire = config.createResolver({
        asSrc: false,
        isRequire: true,
    })

    const resolve = (
        id: string, // 当前文件
        importer: string, // 导入该文件的文件地址,绝对路径
        kind: ImportKind, // 导入类型
        resolveDir?: string
    ): Promise<string | undefined> => {
        let _importer: string
        if (resolveDir) {
            _importer = normalizePath(path.join(resolveDir, '*'))
        } else {
            // importer 表示导入该文件的文件
            // 如果 importer 在 qualified 中存在则设置对应文件路径,反之设置 importer
            _importer = importer in qualified ? qualified[importer] : importer
        }
        const resolver = kind.startsWith('require') ? _resolveRequire : _resolve
        // 根据不同模块类型返回不同的路径查找函数
        return resolver(id, _importer, undefined, ssr)
    }

    return {
        name: 'vite:dep-pre-bundle',
        setup(build) {},
    }
}

esbuildDepPlugin函数会创建一个函数,这个函数的作用是根据模块种类返回不同的路径查找函数;并返回一个插件对象

这个插件对象主要功能和钩子函数如下

// qualified 包含所有入口模块
// 如果 flatId 在入口模块里面,设置 namespace 为 dep
function resolveEntry(id: string) {
    const flatId = flattenId(id)
    if (flatId in qualified) {
        return {
            path: flatId,
            namespace: 'dep',
        }
    }
}
// 拦截裸模块
build.onResolve(
    { filter: /^[\w@][^:]/ },
    async ({ path: id, importer, kind }) => {
        let entry: { path: string; namespace: string } | undefined
        
        if (!importer) {
            // 如果没有 importer 说明是入口文件
            // 调用 resolveEntry 方法,如果有返回值直接返回
            if ((entry = resolveEntry(id))) return entry
            // 入口文件可能带有别名,去掉别名之后再调用 resolveEntry 方法
            const aliased = await _resolve(id, undefined, true)
            if (aliased && (entry = resolveEntry(aliased))) {
                return entry
            }
        }

        // use vite's own resolver
        const resolved = await resolve(id, importer, kind)
        if (resolved) {
            // ...
            
            // http(s)类型的路径
            if (isExternalUrl(resolved)) {
                return {
                    path: resolved,
                    external: true,
                }
            }
            return {
                path: path.resolve(resolved)
            }
        }
    }
)

这个钩子函数的作用是

  • 对预构建模块入口文件设置namespace设置为dep`
  • http(s)类型的路径不打包到 bundle 中,保持原样不变
  • 其他类型的只返回路径

对于入口文件还有一个build.onLoad钩子函数,内容如下

const root = path.resolve(config.root)
build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
    // 获取 id 对应的绝对路径
    const entryFile = qualified[id]
    // 获取 id 相对于 root 的路径
    let relativePath = normalizePath(path.relative(root, entryFile))
    // 拼接路径
    if (
        !relativePath.startsWith('./') &&
        !relativePath.startsWith('../') &&
        relativePath !== '.'
    ) {
        relativePath = `./${relativePath}`
    }

    let contents = ''
    const data = exportsData[id]
    // 获取导入和导出信息
    const [imports, exports] = data
    if (!imports.length && !exports.length) {
        // cjs
        contents += `export default require("${relativePath}");`
    } else {
        if (exports.includes('default')) {
            contents += `import d from "${relativePath}";export default d;`
        }
        if (
            data.hasReExports ||
            exports.length > 1 ||
            exports[0] !== 'default'
        ) {
            contents += `\nexport * from "${relativePath}"`
        }
    }

    let ext = path.extname(entryFile).slice(1)
    if (ext === 'mjs') ext = 'js'
    return {
        loader: ext as Loader,
        contents,
        resolveDir: root,
    }
})

这个钩子函数的作用就是构建一个虚拟模块,并导入预构建的入口模块。虚拟模块内容如下

  • CommonJS 类型的文件,导出的虚拟模块内容是export default require("模块路径");
  • export default的文件,导出的虚拟模块内容是import d from "模块路径";export default d;
  • 其他 ESM 类型的文件,导出的虚拟模块内容是export * from "模块路径"

之后就通过这个虚拟模块开始打包所有预渲染模块。

预构建模块小结

  • 遍历所有预构建模块,将对应模块的绝对路径添加到flatIdDeps中;读取模块代码,通过es-module-lexer将模块转换成AST,并赋值给exportsData。查找有没有export * from xxx形式的代码,如果有exportsData.hasReExports设置成true。最后将AST赋值给idToExportsflatIdToExports
  • 通过 ESbuild 打包所有预构建模块。并设置bundletrue。从而实现上面说的将有许多内部模块的 ESM 依赖关系转换为单个模块
  • 最后生成预建模块的信息

由此预构建结束。回到runOptimize方法

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

预构建之后的返回值会挂载到server._optimizeDepsMetadata上。

怎么注册新的依赖预编译函数

通过createMissingImporterRegisterFn创建一个函数,并将这个函数挂载到server._registerMissingImport上。这个函数的作用是注册新的依赖预编译

export function createMissingImporterRegisterFn(
    server: ViteDevServer
): (id: string, resolved: string, ssr?: boolean) => void {
    let knownOptimized = server._optimizeDepsMetadata!.optimized
    let currentMissing: Record<string, string> = {}
    let handle: NodeJS.Timeout

    let pendingResolve: (() => void) | null = null

    async function rerun(ssr: boolean | undefined) {}

    return function registerMissingImport(}
}

当需要预编译新的模块时,调用这个返回函数registerMissingImport

return function registerMissingImport(
id: string,
 resolved: string,
 ssr?: boolean
) {
  if (!knownOptimized[id]) {
    // 收集需要预编译的模块
    currentMissing[id] = resolved
    if (handle) clearTimeout(handle)
    handle = setTimeout(() => rerun(ssr), debounceMs)
    server._pendingReload = new Promise((r) => {
      pendingResolve = r
    })
  }
}

函数内,先将需要预编译的模块添加到currentMissing中,然后调用rerun函数

async function rerun(ssr: boolean | undefined) {
  // 获取新的预编译模块
  const newDeps = currentMissing
  currentMissing = {}
  // 合并新老的预编译模块
  for (const id in knownOptimized) {
    newDeps[id] = knownOptimized[id].src
  }
  try {
    server._isRunningOptimizer = true
    server._optimizeDepsMetadata = null
    // 调用 optimizeDeps 函数,开始预编译过程
    const newData = (server._optimizeDepsMetadata = await optimizeDeps(
      server.config,
      true, // 注意这里为 true,说明需要清空缓存重新预编译
      false,
      newDeps, // 传入了 newDeps
      ssr
    ))
    // 更新预编译模块列表
    knownOptimized = newData!.optimized

  } catch (e) {
  } finally {
    server._isRunningOptimizer = false
    pendingResolve && pendingResolve()
    server._pendingReload = pendingResolve = null
  }
  // 清空所有模块的 transformResult 属性
  server.moduleGraph.invalidateAll()
  // 通知客户端重新加载页面
  server.ws.send({
    type: 'full-reload',
    path: '*',
  })
}

上述代码中将新老预编译模块合并,然后调用optimizeDeps函数重新预构建所有模块。需要注意的点是force传入的是true,表示清空缓存重新预编译。而且还传入了newDeps,不会再次收集预构建列表,而是直接使用传入的newDeps

if (!newDeps) {
  ;({ deps, missing } = await scanImports(config))
} else {
  deps = newDeps
  missing = {}
}

当新的预构建完成后,通知客户端重新加载页面。

小结

整个预编译流程如下

导入路径是怎么映射到缓存目录中

当被请求模块中导入了预构建模块时,在重写导入路径的时候会通过preAliasPlugin插件获取并返回预构建后的路径。

看下preAliasPlugin插件中这块逻辑

// preAliasPlugin 插件的 resolveId 内部
resolveId(id, importer, _, ssr) {
  if (!ssr && bareImportRE.test(id)) {
    return tryOptimizedResolve(id, server, importer);
  }
}

调用tryOptimizedResolve方法

// tryOptimizedResolve 内部
const cacheDir = server.config.cacheDir
const depData = server._optimizeDepsMetadata

if (!cacheDir || !depData) return

const getOptimizedUrl = (optimizedData: typeof depData.optimized[string]) => {
  return (
    optimizedData.file +
    `?v=${depData.browserHash}${
    optimizedData.needsInterop ? `&es-interop` : ``
    }`
  )
}

// check if id has been optimized
const isOptimized = depData.optimized[id]
if (isOptimized) {
  return getOptimizedUrl(isOptimized)
}

可以看到,根据传入的id从预构建列表中获取缓存文件的路径,并拼接v参数;对于CommonJS 转成 ESM 的模块,会再拼接一个es-interop参数。分析importAnalysis插件时说过,对于有es-interop参数的 URL 会在导入的地方重写导入逻辑。这也就解释了为什么 CommonJS 模块也可以通过 ESM 的方式引入。