この記事では、Vite での依存関係スキャンの実装詳細について詳しく説明します。最終的なスキャン結果は、複数のモジュールの名前を含むオブジェクトです。事前のビルド プロセスや事前のビルド方法は含まれません。 -組み立てられた製品が使用されます。
Vite を初めて実行するとき、Vite は CommonJS との互換性を保つために 依存関係の事前構築 を実行します。そして パフォーマンスの向上。 [関連する推奨事項: vuejs ビデオ チュートリアル ]
依存関係を事前構築するには、まず次の 2 つの問題を理解する必要があります。スキャンの内容と実装方法に依存します。 この記事では、依存関係スキャンの実装の詳細について詳しく説明します。最終的なスキャン結果は、複数のモジュールの名前を含むオブジェクト
です。これには、事前構築プロセスや事前のビルド方法は含まれません。組み立てられた製品は使用中です。コンテンツのこの部分に興味がある場合は、私をフォローして次の記事を待ってください。事前構築されたコンテンツに依存する
ベアインポートとは何ですか?
#下の例を直接見てください名前でアクセスされるモジュールは裸のモジュールですパスを使用してアクセスされるモジュールはベアではありません import単純に分割できます:// vue 是 bare import import xxx from "vue" import xxx from "vue/xxx" // 以下不是裸依赖 import xxx from "./foo.ts" import xxx from "/foo.ts"ログイン後にコピー
プロジェクトのモジュール依存関係ツリーです。
依存関係スキャンの結果は次のとおりです。 ##[ "vue", "axios" ]
なぜ裸のインポートだけが事前に構築されているのですか?
Node.js は、ベアインポートのアドレス指定メカニズムを定義します- 現在のディレクトリの node_modules の下を検索します。見つからない場合は、ディレクトリがルートパスなので、それ以上に進むことはできません。
bare import は通常、npm によってインストールされたモジュール です。これはサードパーティのモジュールであり、自分たちで書いたコードではありません。通常、 は変更されません
。したがって、これらのモジュールの構築を事前に実行することで、パフォーマンスの向上に役立ちます。逆に、開発者が書いたコードが事前にビルドされている場合、プロジェクトはチャンク ファイルにパッケージ化されます。開発者がコードを変更する場合、ビルドを再実行する必要があります そしてそれをチャンク ファイルにパッケージ化します。このプロセスは実際にパフォーマンスに影響します。
monorepo 下のモジュールも事前にビルドされますか?
それはできません。 Monorepo の場合、一部のモジュールはベアインポートですが、これらのモジュールも開発者自身によって書かれており、サードパーティのモジュールではないため、Vite はこれらのモジュールに対して事前ビルドを実行しません。:実際には、Vite はモジュールの実際のパスが node_modules にあるかどうかを
判断します
node_modules に実際のパスを持つモジュールは事前にビルドされます、これはサードパーティのモジュールです。実際のパスがnode_modulesにない場合、モジュールがファイルリンク(monorepoの実装)を通じてnode_modulesにリンクされていることを証明します。これは開発者によって書かれたコードです。自分自身で事前構築を実行しません
#このモジュールの依存関係ツリーをもう一度見てみましょう :
すべての裸のインポートをスキャンするには、依存関係ツリー全体を走査する必要があります。これには、ツリー
ツリー トラバーサルについて議論するときは、通常次の 2 つの点に焦点を当てます。
いつ深くするのをやめるべきか?
リーフノードをどう扱うか?ベア インポート ノードに遭遇したときは、依存関係を深く調べる必要はありません。
CSS、SVG など、JS と関係のない他のモジュールに遭遇した場合、それらは JS コードではないため、必要はありません。詳細なトラバースを続行するにはリーフ ノードの処理を見てみましょう: モジュール ID で判断できます モジュール ID がパスではないモジュール がベアインポートです。これらのモジュールに遭遇した場合は、 依存関係を記録し、 を深く調べる必要はありません。 たとえば * が見つかった場合は、モジュールの接尾辞名によって 判断できます。 .css コードを AST に変換し、import ステートメントによって導入されたモジュールを取得する必要があります。または、インポートされたすべてのモジュールを定期的に照合します 、その後 これらのモジュールを詳細に調査し続けます そして、それを JS モジュールに従って分析および処理する必要があります。 これらのモジュールを詳しく調べてください。ここでは JS 部分のみに注目する必要があり、他の部分にはモジュールは導入されません。 依存関係スキャンの実装アイデアはすでにわかっています。アイデアは実際には「複雑ではありません。複雑なのは処理 Vite はここでより賢い方法を使用しています - esbuild ツールを使用したパッケージ化 なぜ esbuild パッケージングを使用してディープ トラバーサル プロセスを置き換えることができるのでしょうか? 本質的に
由于依赖扫描过程,只关注引入的 JS 模块,因此可以直接丢弃掉其他不需要的内容,直接取其中 JS html 类型文件(包括 vue)的转换,有两种情况: 什么是虚拟模块? 是模块的内容并非直接从磁盘中读取,而是编译时生成。 举个例子, 为什么需要虚拟模块? 因为一个 html 类型文件中,允许有多个 script 标签,多个内联的 script 标签,其内容无法处理成一个 JS 文件 (因为可能会有命名冲突等原因) 既然无法将多个内联 script,就只能将它们分散成多个虚拟模块,然后分别引入了。 依赖扫描的入口 下面是扫描依赖的入口函数(为了便于理解,有删减和修改): 主要流程如下: 将项目内所有的 html 作为入口文件(排除 node_modules)。 将每个入口文件,用 esbuild 进行打包 这里的核心其实是 esbuild 插件 很多同学可能不知道 esbuild 插件是如何编写的,这里简单介绍一下: 每个模块都会经过解析(resolve)和加载(load)的过程: 插件可以定制化解析和加载的过程,下面是一些插件示例代码: 扫描插件的实现 部分参数解析: 例如 esbuild 默认能将模块路径转成真实路径,为什么还要用 因为 Vite/Rollup 的插件,也能扩展解析的流程,例如 alias 的能力,我们常常会在项目中用 因此不能用 esbuild 原生的解析流程进行解析。 这里 那么接下来就是插件的实现了,先回顾一下之前写的各类模块的处理: esbuild 本身就能处理 JS 语法,因此 JS 是不需要任何处理的,esbuild 能够分析出 JS 文件中的依赖,并进一步深入处理这些依赖。 这部分处理非常简单,直接匹配,然后 external 就行了 如: 解析过程很简单,只是用于过滤掉一些不需要的模块,并且标记 namespace 为 html 真正的处理在加载阶段: 加载阶段的主要做有以下流程: srcMatch[1] || srcMatch[2] || srcMatch[3] 是干嘛? 我们来看看匹配的表达式: 因为 src 可以有以下三种写法: 三种情况会出现其中一种,因此是三个捕获组 虚拟模块是如何加载成对应的 script 代码的? 虚拟模块的加载很简单,直接从 script 对象中,读取之前缓存起来的内容即可。 这样之后,我们就可以把 html 类型的模块,转换成 JS 了 扫描结果 下面是一个 depImport 对象的例子: 依赖扫描是预构建前的一个非常重要的步骤,这决定了 Vite 需要对哪些依赖进行预构建。 本文介绍了 Vite 会对哪些内容进行依赖预构建,然后分析了实现依赖扫描的基本思路 —— 深度遍历依赖树,并对各种类型的模块进行处理。然后介绍了 Vite 如何巧妙的使用 esbuild 实现这一过程。最后对这部分的源码进行了解析: 最后获取到的 depImport 是一个记录依赖以及其真实路径的对象 モジュール、
は処理を必要とせず、それ以上の走査 も必要ありません。 JS モジュール
JS コード内の依存サブモジュールを取得するには、
JS コードのこの部分を抽出する必要があります。
。代替方法は次のとおりです: #深さのトラバーサル
各種モジュールの処理
esbuild パッケージ化 リーフ ノードの処理
esbuild は esbuild は、解析プロセス中に現在解析されているモジュールを 各モジュール (リーフ ノード) を実行できます) 解析とロード
これら 2 つのプロセスはプラグインを通じて 拡張でき、特別なロジックを追加できます
たとえば、ロード プロセス中に html を js に変換します
##モジュールを詳細に処理しないでくださいexternal モジュールを通常どおり解析し (何もせず、esbuild のデフォルト動作)、モジュールのファイルの実際のパスを返します として指定できます。その後、esbuild は実行しません。モジュール を詳細に解析してロードする時間が長くなります。
モジュールを詳細に調べます
この部分は今は理解していなくても問題ありません。後ほど例を挙げます。
例
vue
処理
##ベアインポート
解析プロセス中に、 裸の依存関係を deps オブジェクトに保存し、それを external # に設定します。
##その他の JS 非依存モジュール
less file
解析プロセス中、 は external #JS module## に設定されます
./mian.ts
通常通り解析してロードするだけで、esbuild 自体が JS
# を処理できます ##html タイプ module index.html
、 app.vue# 読み込みプロセス中、これらのモジュール
JSにロード
<p>最后 <strong>dep 对象中收集到的依赖就是依赖扫描的结果</strong>,而这次 esbuild 的打包产物,其实是没有任何作用的,在依赖扫描过程中,我们<strong>只关心每个模块的处理过程,不关心构建产物</strong></p>
<blockquote><p>用 Rollup 处理可以吗?</p></blockquote>
<p>其实也可以,打包工具基本上都会有解析和加载的流程,也能对模块进行 external</p>
<p>但是 esbuild 性能更好</p>
<h3 data-id="heading-4"><strong>html 类型的模块处理</strong></h3>
<p>这类文件有 <code>html
、vue
等。之前我们提到了要将它们转换成 JS,那么到底要如何转换呢?
import
语句,引入外部 scriptsrc/main.ts
是磁盘中实际存在的文件,而 virtual-module:D:/project/index.html?id=0
在磁盘中是不存在的,需要借助打包工具(如 esbuild),在编译过程生成。源码解析
import { build } from 'esbuild'
export async function scanImports(config: ResolvedConfig): Promise
missing: Record<string>
}> {
// 将项目中所有的 html 文件作为入口,会排除 node_modules
let entries: string[] = await globEntries('**/*.html', config)
// 扫描到的依赖,会放到该对象
const deps: Record<string> = {}
// 缺少的依赖,用于错误提示
const missing: Record<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
}
}</string></string></string>
esbuildScanPlugin
插件的实现,它定义了各类模块(叶子节点)的处理方式。function esbuildScanPlugin(config, container, deps, missing, entries){}
dep
、missing
对象被当做入参传入,在函数中,这两个对象的内容会在打包(插件运行)过程中被修改
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>,
missing: Record<string>,
entries: string[]
): Plugin</string></string>
config
:Vite 的解析好的用户配置container
:这里只会用到 container.resolveId
的方法,这个方法能将模块路径转成真实路径。vue
转成 xxx/node_modules/dist/vue.esm-bundler.js
。depImports
:用于存储扫描到的依赖对象,插件执行过程中会被修改missing
:用于存储缺少的依赖的对象,插件执行过程中会被修改entries
:存储所有入口文件的数组container.resolveId
?@
的别名代表项目的 src
路径。container
(插件容器)用于兼容 Rollup 插件生态,用于保证 dev 和 production 模式下,Vite 能有一致的表现。感兴趣的可查看《Vite 是如何兼容 Rollup 插件生态的》container.resolveId
会被再次包装一成 resolve
函数(多了缓存能力)const seen = new Map<string>()
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
}</string>
例子
处理
bare import
vue
在解析过程中,将裸依赖保存到 deps 对象中,设置为 external
其他 JS 无关的模块
less文件
在解析过程中,设置为 external
JS 模块
./mian.ts
正常解析和加载即可,esbuild 本身能处理 JS
html 类型模块
index.html
、app.vue
在加载过程中,将这些模块加载成 JS
JS 模块
其他 JS 无关的模块
// external urls
build.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 无关的模块
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)
}
}
)
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'
}
})
// 正则,匹配例子: <script></script>
const scriptModuleRE = /(<script>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 正则,匹配例子: <script></script>
export const scriptRE = /(<script>]*>|>))(.*?)<\/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>
// 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>
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im
export const virtualModuleRE = /^virtual-module:.*/
// 匹配所有的虚拟模块,namespace 标记为 script
build.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]
})
{
"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"
}
总结
以上がVite Learning の深層分析「依存関係スキャン」の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。