查看原文
其他

深度解读Vite的依赖扫描

The following article is from Candy 的修仙秘籍 Author Candy


导语 | 本文推选自腾讯云开发者社区-【技思广益 · 腾讯技术人原创集】专栏。该专栏是腾讯云开发者社区为腾讯技术人与广泛开发者打造的分享交流窗口。栏目邀约腾讯技术人分享原创的技术积淀,与广泛开发者互启迪共成长。本文作者是腾讯云前端开发工程师CandyTong。


本文会深入地讲解依赖扫描的实现细节,最终的扫描结果是一个包含多个模块的名字的对象,不涉及预构建的过程、预构建产物如何是使用的。如果对该部分内容感兴趣,可以关注我,等待后续文章。欢迎点击文末「阅读原文」访问腾讯云开发者社区,查看作者作品~


当我们首次运行Vite的时候,Vite会执行依赖预构建,目的是为了兼容 CommonJS和UMD,以及提升性能。


要对依赖进行预构建,首先要搞清楚这两个问题:


  • 预构建的内容是什么?/哪些模块需要进行预构建?


  • 如何找到需要预构建的模块?


这两个问题,其实就是依赖扫描的内容以及实现方式。



依赖预构建的内容


一个项目中,存在非常多的模块,并不是所有模块都会被预构建。只有bare import(裸依赖)会执行依赖预构建。


什么是bare import?


直接看下面这个例子:


// vue 是 bare importimport xxx from "vue"import xxx from "vue/xxx"
// 以下不是裸依赖import xxx from "./foo.ts" import xxx from "/foo.ts"


可以简单的划分一下:


  • 用名称去访问的模块是裸模块。


  • 用路径去访问的模块,不是bare import。


实际上Vite也是这么判断的。


下面是一个常见的Vue项目的模块依赖树:



依赖扫描的结果如下:


[ "vue", "axios" ]


为什么只对bare import进行预构建?


Node.js定义了bare import的寻址机制——在当前目录下的node_modules下寻找,找不到则往上一级目录的node_modules,直到目录为根路径,不能再往上。


bare import一般是npm安装的模块,是第三方的模块,不是我们自己写的代码,一般情况下是不会被修改的,因此对这部分的模块提前执行构建,有利于提升性能。


相反,如果对开发者写的代码执行预构建,将项目打包成chunk文件,当开发者修改代码时,就需要重新执行构建,再打包成chunk文件,这个过程反而会影响性能。


monorepo下的模块也会被预构建吗?


不会。因为monorepo的情况下,部分模块虽然是bare import,但这些模块也是开发者自己写的,不是第三方模块,因此Vite没有对该部分的模块执行预构建。


实际上,Vite会判断模块的实际路径,是否在node_modules中:


  • 实际路径在node_modules的模块会被预构建,这是第三方模块


  • 实际路径不在node_modules的模块,证明该模块是通过文件链接,链接到node_modules内的(monorepo的实现方式),是开发者自己写的代码,不执行预构建。



依赖扫描


(一)实现思路


我们再来看看这棵模块依赖树:



要扫描出所有的bare import,就需要遍历整个依赖树,这就涉及到了树的深度遍历。


当我们在讨论树的遍历时,一般会关注这两点:


  • 什么时候停止深入?


  • 如何处理叶子节点?



当前叶子节点不需要继续深入遍历的情况:


  • 当遇到bare import节点时,记录下该依赖,就不需要继续深入遍历。


  • 遇到其他JS无关的模块,如CSS、SVG等,因为不是JS代码,因此也不需要继续深入遍历。


当所有的叶子节点遍历完成后,记录的bare import对象,就是依赖扫描的结果


依赖扫描的实现思路其实非常容易理解,但实际的处理就不简单了。


我们来看看叶子节点的处理:


  • bare import


可以通过模块 id 判断,**模块 id 不为路径的模块**,就是 bare import。遇到这些模块则**记录依赖,不再深入遍历**。


  • 其他JS无关的模块


可以**通过模块的后缀名判断**,例如遇到 `*.css` 的模块,**无需任何处理,不再深入遍历**。


  • JS模块


要获取 JS 代码中依赖的子模块,就需要**将代码转成 AST,获取其中 import 语句引入的模块,或者正则匹配出所有 import 的模块**,然后**继续深入遍历**这些模块


  • HTML类型模块


这类模块比较复杂,例如 HTML 或 Vue,里面有一部分是 JS,需要**把这部分 JS 代码提取出来**,然后按 JS 模块进行分析处理,**继续深入遍历**这些模块。这里只需要关心 JS 部分,其他部分不会引入模块。




(二)具体实现


我们已经知道了依赖扫描的实现思路,思路其实不复杂,复杂的是处理过程,尤其是HTML、Vue等模块的处理。


Vite这里用了一种比较巧妙的办法 ——用esbuild工具打包


为什么可以用esbuild打包代替深度遍历的过程?


本质上打包过程也是个深度遍历模块的过程,其替代的方式如下:



这块暂时看不懂没有关系,后面会有例子:



各类模块的处理:



最后dep对象中收集到的依赖就是依赖扫描的结果,而这次esbuild的打包产物,其实是没有任何作用的,在依赖扫描过程中,我们只关心每个模块的处理过程,不关心构建产物。


用Rollup处理可以吗?


其实也可以,打包工具基本上都会有解析和加载的流程,也能对模块进行external。


但是esbuild性能更好


html类型的模块处理


这类文件有html、vue等。之前我们提到了要将它们转换成JS,那么到底要如何转换呢?


由于依赖扫描过程,只关注引入的JS模块,因此可以直接丢弃掉其他不需要的内容,直接取其中JS。


html类型文件(包括vue)的转换,有两种情况:


  • 每个外部script,会直接转换为import语句,引入外部script


  • 每个内联script,其内容将会作为虚拟模块被引入。


什么是虚拟模块?


是模块的内容并非直接从磁盘中读取,而是编译时生成。


举个例子,src/main.ts是磁盘中实际存在的文件,而virtual-module:D:/project/index.html?id=0在磁盘中是不存在的,需要借助打包工具(如esbuild),在编译过程生成。


为什么需要虚拟模块?


因为一个html类型文件中,允许有多个script标签,多个内联的script标签,其内容无法处理成一个JS文件(因为可能会有命名冲突等原因)


既然无法将多个内联script,就只能将它们分散成多个虚拟模块,然后分别引入了。



源码解析


(一)依赖扫描的入口


下面是扫描依赖的入口函数(为了便于理解,有删减和修改):


import { build } from 'esbuild'export async function scanImports(config: ResolvedConfig): Promise<{ deps: Record<string, string> missing: Record<string, string>}> { // 将项目中所有的 html 文件作为入口,会排除 node_modules let entries: string[] = await globEntries('**/*.html', config)
// 扫描到的依赖,会放到该对象 const deps: Record<string, string> = {} // 缺少的依赖,用于错误提示 const missing: Record<string, string> = {} // esbuild 扫描插件,这个是重点!!! const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
// 获取用户配置的 esbuild 自定义配置,没有配置就是空的 const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
await Promise.all( // 入口可能不止一个,分别用 esbuid 打包 entries.map((entry) => // esbuild 打包 build({ absWorkingDir: process.cwd(), write: false, entryPoints: [entry], bundle: true, format: 'esm', // 使用插件 plugins: [...plugins, plugin], ...esbuildOptions }) ) )
return { deps, missing }}


主要流程如下:


  • 将项目内所有的html作为入口文件(排除node_modules)。


  • 将每个入口文件,用esbuild进行打包


这里的核心其实是esbuildScanPlugin插件的实现,它定义了各类模块(叶子节点)的处理方式。


function esbuildScanPlugin(config, container, deps, missing, entries){}



  • dep、missing对象被当做入参传入,在函数中,这两个对象的内容会在打包(插件运行)过程中被修改。



(二)esbuild插件


很多同学可能不知道esbuild插件是如何编写的,这里简单介绍一下:


每个模块都会经过解析(resolve)和加载(load)的过程:


  • 解析:将模块路径,解析成文件真实的路径。例如vue,会解析到实际node_modules中的vue的入口js文件。


  • 加载:根据解析的路径,读取文件的内容。



插件可以定制化解析和加载的过程,下面是一些插件示例代码:


const plugin = { name: 'xxx', setup(build) { // 定制解析过程,所有的 http/https 的模块,都会被 external build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({ path, external: true })) // 定制解析过程,给所有 less 文件 namespace: less 标记 build.onResolve({ filter: /.*\.less/ }, args => ({ path: args.path, namespace: 'less', }))
// 定义加载过程:只处理 namespace 为 less 的模块 build.onLoad({ filter: /.*/, namespace: 'less' }, () => { const raw = fs.readFileSync(path, 'utf-8') const content = // 省略 less 处理,将 less 处理成 css return { contents, loader: 'css' } }) }}


  • 通过onResolve、onLoad定义解析和加载过程。


  • onResolve的第一个参数为过滤条件,第二个参数为回调函数,解析时调用,返回值可以给模块做标记,如external、namespace(用于过滤),还需要返回模块的路径。


  • 每个模块,onResolve会被依次调用,直到回调函数返回有效的值,后面的不再调用。如果都没有有效返回,则使用默认的解析方式


  • onLoad的第一个参数为过滤条件,第二个参数为回调函数,加载时调用,可以读取文件的内容,然后进行处理,最后返回加载的内容。


  • 每个模块,onLoad会被依次调用,直到回调函数返回有效的值,后面的不再调用。如果都没有有效返回,则使用默认的加载方式。



(三)扫描插件的实现


function esbuildScanPlugin( config: ResolvedConfig, container: PluginContainer, depImports: Record<string, string>, missing: Record<string, string>, entries: string[]): Plugin


部分参数解析:


  • configVite的解析好的用户配置。


  • container:这里只会用到container.resolveId的方法,这个方法能将模块路径转成真实路径。


例如vue转成xxx/node_modules/dist/vue.esm-bundler.js。


  • depImports:用于存储扫描到的依赖对象,插件执行过程中会被修改。


  • missing:用于存储缺少的依赖的对象,插件执行过程中会被修改。


  • entries:存储所有入口文件的数组。


esbuild默认能将模块路径转成真实路径,为什么还要用 container.resolveId?


因为Vite/Rollup的插件,也能扩展解析的流程,例如alias的能力,我们常常会在项目中用@的别名代表项目的src路径。


因此不能用esbuild原生的解析流程进行解析。


container(插件容器)用于兼容Rollup插件生态,用于保证dev和production模式下,Vite能有一致的表现。感兴趣的可查看《Vite是如何兼容Rollup插件生态的》


这里container.resolveId会被再次包装一成resolve函数(多了缓存能力)


const seen = new Map<string, string | undefined>()const resolve = async ( id: string, importer?: string, options?: ResolveIdOptions) => { const key = id + (importer && path.dirname(importer)) // 如果有缓存,就直接使用缓存 if (seen.has(key)) { return seen.get(key) } // 将模块路径转成真实路径 const resolved = await container.resolveId( id, importer && normalizePath(importer), { ...options, scan: true } ) // 缓存解析过的路径,之后可以直接获取 const res = resolved?.id seen.set(key, res) return res }


那么接下来就是插件的实现了,先回顾一下之前写的各类模块的处理:



JS模块


esbuild本身就能处理JS语法,因此JS是不需要任何处理的,esbuild能够分析出JS文件中的依赖,并进一步深入处理这些依赖。


其他JS无关的模块


// external urlsbuild.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({ path, external: true}))
// external css 等文件build.onResolve( { filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json|wasm)$/ }, ({ path }) => ({ path, external: true }) // 省略其他 JS 无关的模块


这部分处理非常简单,直接匹配,然后external就行了


bare import


build.onResolve( { // 第一个字符串为字母或 @,且第二个字符串不是 : 冒号。如 vite、@vite/plugin-vue // 目的是:避免匹配 window 路径,如 D:/xxx filter: /^[\w@][^:]/ }, async ({ path: id, importer, pluginData }) => { // depImports 为 if (depImports[id]) { return externalUnlessEntry({ path: id }) } // 将模块路径转换成真实路径,实际上调用 container.resolveId const resolved = await resolve(id, importer, { custom: { depScan: { loader: pluginData?.htmlType?.loader } } }) // 如果解析到路径,证明找得到依赖 // 如果解析不到路径,则证明找不到依赖,要记录下来后面报错 if (resolved) { if (shouldExternalizeDep(resolved, id)) { return externalUnlessEntry({ path: id }) } // 如果模块在 node_modules 中,则记录 bare import if (resolved.includes('node_modules')) { // 记录 bare import depImports[id] = resolved
return { path, external: true } } // isScannable 判断该文件是否可以扫描,可扫描的文件有 JS、html、vue 等 // 因为有可能裸依赖的入口是 css 等非 JS 模块的文件 else if (isScannable(resolved)) { // 真实路径不在 node_modules 中,则证明是 monorepo,实际上代码还是在用户的目录中 // 是用户自己写的代码,不应该 external return { path: path.resolve(resolved) } } else { // 其他模块不可扫描,直接忽略,external return { path, external: true } } } else { // 解析不到依赖,则记录缺少的依赖 missing[id] = normalizePath(importer) } })


  • 如果文件在node_modules中,才认为是bare import,记录当前模块。


  • 文件不在node_modules中,则是monorepo,是用户自己写的代码。


  • 如果这些代码isScanable可扫描(即含有JS代码),则继续深入处理。


  • 其他非JS模块,external。


html类型模块


如:index.html、app.vue


const htmlTypesRE = /\.(html|vue|svelte|astro)$/
// html types: 提取 script 标签build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => { // 将模块路径,转成文件的真实路径 const resolved = await resolve(path, importer) if (!resolved) return // 不处理 node_modules 内的 if (resolved.includes('node_modules'){ return }
return { path: resolved, // 标记 namespace 为 html namespace: 'html' }})


解析过程很简单,只是用于过滤掉一些不需要的模块,并且标记 namespace为html。


真正的处理在加载阶段:



// 正则,匹配例子: <script type=module></script>const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims// 正则,匹配例子: <script></script>export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/script>/gims
build.onLoad( { filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => { // 读取源码 let raw = fs.readFileSync(path, 'utf-8') // 去掉注释,避免后面匹配到注释 raw = raw.replace(commentRE, '<!---->')
const isHtml = path.endsWith('.html') // scriptModuleRE:<script type=module></script> // scriptRE:<script></script> // html 模块,需要匹配 module 类型的 script,因为只有 module 类型的 script 才能使用 import const regex = isHtml ? scriptModuleRE : scriptRE
// 重置正则表达式的索引位置,因为同一个正则表达式对象,每次匹配后,lastIndex 都会改变 // regex 会被重复使用,每次都需要重置为 0,代表从第 0 个字符开始正则匹配 regex.lastIndex = 0 // load 钩子返回值,表示加载后的 js 代码 let js = '' let scriptId = 0 let match: RegExpExecArray | null
// 匹配源码的 script 标签,用 while 循环,因为 html 可能有多个 script 标签 while ((match = regex.exec(raw))) { // openTag: 它的值的例子:<script type="module" lang="ecmascript" src="xxx"> // content: script 标签的内容 const [, openTag, content] = match // 正则匹配出 openTag 中的 type 和 lang 属性 const typeMatch = openTag.match(typeRE) const type = typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3]) const langMatch = openTag.match(langRE) const lang = langMatch && (langMatch[1] || langMatch[2] || langMatch[3]) // 跳过 type="application/ld+json" 和其他非 non-JS 类型 if ( type && !( type.includes('javascript') || type.includes('ecmascript') || type === 'module' ) ) { continue } // esbuild load 钩子可以设置 应的 loader let loader: Loader = 'js' if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') { loader = lang } else if (path.endsWith('.astro')) { loader = 'ts' } // 正则匹配出 script src 属性 const srcMatch = openTag.match(srcRE) // 有 src 属性,证明是外部 script if (srcMatch) { const src = srcMatch[1] || srcMatch[2] || srcMatch[3] // 外部 script,改为用 import 用引入外部 script js += `import ${JSON.stringify(src)}\n` } else if (content.trim()) { // 内联的 script,它的内容要做成虚拟模块
// 缓存虚拟模块的内容 // 一个 html 可能有多个 script,用 scriptId 区分 const key = `${path}?id=${scriptId++}` scripts[key] = { loader, content, pluginData: { htmlType: { loader } } }
// 虚拟模块的路径,如 virtual-module:D:/project/index.html?id=0 const virtualModulePath = virtualModulePrefix + key js += `export * from ${virtualModulePath}\n` } }
return { loader: 'js', contents: js } })


加载阶段的主要做有以下流程:


  • 读取文件源码。


  • 正则匹配出所有的script标签,并对每个script标签的内容进行处理。


  • 外部script,改为用import引入。


  • 内联script,改为引入虚拟模块,并将对应的虚拟模块的内容缓存到script对象。


  • 最后返回转换后的js。


srcMatch1||srcMatch2||srcMatch3是干嘛?


我们来看看匹配的表达式:


const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im


因为src可以有以下三种写法:


  • src="xxx"


  • src='xxx'


  • src=xxx


三种情况会出现其中一种,因此是三个捕获组。


虚拟模块是如何加载成对应的script代码的?


export const virtualModuleRE = /^virtual-module:.*/
// 匹配所有的虚拟模块,namespace 标记为 scriptbuild.onResolve({ filter: virtualModuleRE }, ({ path }) => { return { // 去掉 prefix // virtual-module:D:/project/index.html?id=0 => D:/project/index.html?id=0 path: path.replace(virtualModulePrefix, ''), namespace: 'script' }})
// 之前的内联 script 内容,保存到 script 对象,加载虚拟模块的时候取出来build.onLoad({ filter: /.*/, namespace: 'script' }, ({ path }) => { return scripts[path]})


虚拟模块的加载很简单,直接从script对象中,读取之前缓存起来的内容即可。


这样之后,我们就可以把html类型的模块,转换成JS了。



(四)扫描结果


下面是一个depImport对象的例子:


{ "vue": "D:/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.runtime.esm-bundler.js", "vue/dist/vue.d.ts": "D:/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.d.ts", "lodash-es": "D:/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/lodash.js"}


  • key:模块名称。


  • value:模块的真实路径。



总结


依赖扫描是预构建前的一个非常重要的步骤,这决定了Vite需要对哪些依赖进行预构建。


本文介绍了Vite会对哪些内容进行依赖预构建,然后分析了实现依赖扫描的基本思路——深度遍历依赖树,并对各种类型的模块进行处理。然后介绍了Vite如何巧妙的使用esbuild实现这一过程。最后对这部分的源码进行了解析:


  • 最复杂的就是html类型模块的处理,需要使用虚拟模块。


  • 当遇到bare import时,需要判断是否在node_modules中,在的才记录依赖,然后external。


  • 其他JS无关的模块就直接external。


  • JS模块由于esbuild本身能处理,不需要做任何的特殊操作。


最后获取到的depImport是一个记录依赖以及其真实路径的对象。

扩展资料:

1.Vite是如何兼容Rollup插件生态的

2.Vite Server是如何处理页面资源的

3.五千字剖析vite是如何对配置文件进行解析的

4.手把手教你手写一个Vite Server(一)



 作者简介


CandyTong

腾讯云开发者社区【技思广益·腾讯技术人原创集】作者

腾讯前端开发工程师,目前负责反洗钱业务相关的前端开发工作,有丰富的中后台系统开发经验。



 推荐阅读


TVP 尖峰对话:透过喧嚣探寻低代码的技术本我

Go 1.18 版本新特性详解!

【腾讯云原生】腾讯云跨账号流量统一接入与治理方案

地产行业,如何跑赢「黑铁时代」?



👇点击「阅读原文」查看作者关于Vite内容的更多解读~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存