Vite 源码(八)Vite 的预构建原理 您所在的位置:网站首页 vite引入commonjs模块 Vite 源码(八)Vite 的预构建原理

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

2023-05-26 04:37| 来源: 网络整理| 查看: 265

先看下官网介绍

当你首次启动 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.json, yarn.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, // 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 文件的路径,一般在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, missing: Record if (!newDeps) { ;({ deps, missing } = await scanImports(config)) } else { deps = newDeps missing = {} } 复制代码 自动依赖搜寻

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

export async function scanImports(config: ResolvedConfig): Promise { 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 = {} const missing: Record = {} // 创建插件容器 const container = await createPluginContainer(config) // 创建 esbuildScanPlugin 插件 const plugin = esbuildScanPlugin(config, container, deps, missing, entries) // 获取 预构建的 plugins 和 配置项 const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {} 复制代码

接下来就是定义查找预构建模块需要的变量,比如插件容器container、esbuildScanPlugin插件、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, 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、不知名文件 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中存在,并且是mjs、js、ts文件,则将这个模块添加到depImports中

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

HTML、Vue 文件

对于 HTML、Vue文件,设置namespace为html

// 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钩子函数时,namespace为html会命中一个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]: 开始标签 // scriptModuleRE[2]、scriptRE[1]: script 标签的内容 // scriptRE[1]: 开始标签 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 + 替换成 __,将 \ 和 . 替换成 _ 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赋值给idToExports和flatIdToExports

flatIdToExports、idToExports、flatIdDeps结构如下

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

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

// 构建过程中要替换的字符串 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,后面会分析,继续向下

生成预建模块的信息 // 获取打包到一起的文件生成的依赖图 const meta = resultafile! // 获取 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, 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 => { 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赋值给idToExports和flatIdToExports 通过 ESbuild 打包所有预构建模块。并设置bundle为true。从而实现上面说的将有许多内部模块的 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 = {} 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 的方式引入。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有