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


theme: cyanosis

highlight: monokai

先看下官网介绍

当你首次启动 vite 时,你可能会注意到打印出了以下信息: bash 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),按名导入也会符合预期效果: typescript // 符合预期 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) 会自动使它们失效

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

源码

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

```typescript 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方法 typescript 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方法 typescript 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方法,这个方法比较大,这里我们分步来看他做了啥 ```typescript export async function optimizeDeps( config: ResolvedConfig, force = config.server.force, // 设置为 true 强制使依赖预构建 asCommand = false, newDeps?: Record , // missing imports encountered after server has started ssr?: boolean ): Promise { // 重新赋值 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文件的路径,一般在nodemodules/.vite/_metadata.json。然后通过getDepHash生成 hash 值。并创建一个data`对象

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

typescript 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"

typescript // 如果有 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方法收集列表

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

自动依赖搜寻

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

```typescript export async function scanImports(config: ResolvedConfig): Promise<{ deps: Record missing: Record }> { 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`文件

typescript 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 配置项

```typescript // 打包每个入口文件,并将引入的 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`插件,这个插件就是查找的核心,看下这个插件的实现

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

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

// 通过 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、不知名文件

```typescript 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 中
}))

} ```

第三方库

上面这些比较简单,这里就不多介绍了,接下来看下对于第三方依赖是怎么处理的 typescript 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 typescript // 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钩子函数 ``typescript 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选项,如果有将这个选项内容添加到代码中 ```typescript build.onLoad({ filter: JSTYPESRE }, ({ 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(未找到的模块)

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

depsmissing的数据结构如下

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

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

typescript let deps: Record<string, string>, missing: Record<string, string> if (!newDeps) { ;({ deps, missing } = await scanImports(config)) } else { deps = newDeps missing = {} } 继续向下 ```typescript // 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 配置项 ```typescript // 需要预构建的模块数量 const total = qualifiedIds.length const maxListed = 5 const listed = Math.min(total, maxListed) const flatIdDeps: Record = {} const idToExports: Record = {} const flatIdToExports: Record = {}

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

收集

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

typescript 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结构如下

```bash

id: 导入的模块或导入的路径

flatId: 将 > 替换成 __,将 \ 和 . 替换成 _ 的导入路径/模块

flatIdDeps: { flatId: 对应模块的绝对路径 } idToExports: { id: 对应模块的AST,是一个数组 } flatIdToExports: { flatId: 对应模块的AST,是一个数组 } ```

开始构建

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

```typescript // 构建过程中要替换的字符串 const define: Record = { '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`,后面会分析,继续向下

生成预建模块的信息

```typescript // 获取打包到一起的文件生成的依赖图 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。就像下面这样

```typescript // a.ts module.exports = { test: 1 } // 编译后 import { __commonJS } from "./chunk-Z47AEMLX.js";

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

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

_metadata.json介绍

假设项目中只引入了 Vue,生成的_metadata.json如下 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插件定义如下

```typescript export function esbuildDepPlugin( qualified: Record , exportsData: Record , 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`函数会创建一个函数,这个函数的作用是根据模块种类返回不同的路径查找函数;并返回一个插件对象

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

```typescript // 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钩子函数,内容如下

``typescript 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方法

typescript 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上。这个函数的作用是注册新的依赖预编译

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

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

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

return function registerMissingImport(}

} ```

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

typescript 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函数

```typescript 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

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

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

小结

整个预编译流程如下

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

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

看下preAliasPlugin插件中这块逻辑

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

调用tryOptimizedResolve方法

```typescript // 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 的方式引入。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值