依赖预构建的目的
- 目的是为了兼容 CommonJS 和 UMD,以及提升性能。
需要预构建的模块
- 只有 bare import(裸依赖)会执行依赖预构建
- bare import:一般是 npm 安装的模块,是第三方的模块,不是我们自己写的代码,一般情况下是不会被修改的,因此对这部分的模块提前执行构建并且进行缓存,有利于提升性能。
- monorepo下的模块不会被预构建,部分模块虽然是 bare import,但这些模块也是开发者自己写的,不是第三方模块,因此 Vite 没有对该部分的模块执行预构建。
import vue from "vue"
import xxx from "vue/xxx"
import foo from "./foo.ts"
import foo1 from "/foo.ts"
- vite的判断
-
实际路径在 node_modules 的模块会被预构建,这是第三方模块 -
实际路径不在 node_modules 的模块,证明该模块是通过文件链接,链接到 node_modules 内的(monorepo 的实现方式),是开发者自己写的代码,不执行预构建 -
如下[‘vue’,‘axios’]会被判断为裸模块
依赖扫描方式
-
树的深度遍历
-
当前节点不需要继续深度的情况
- 当遇到 bare import 节点时,记录下该依赖,就不需要继续深入遍历
- 遇到其他 JS 无关的模块,如 CSS、SVG 等,因为不是 JS 代码,因此也不需要继续深入遍历
- 当所有的节点遍历完成后,记录的 bare import 对象,就是依赖扫描的结果。
-
子节点的处理
- bare import
- 通过模块id,模块 id 不为路径的模块,就是 bare import。遇到这些模块则记录依赖,不再深入遍历
- 在解析过程中,将裸依赖保存到 deps 对象中,设置为 external
- 其他 js 无关的模块
- 可以通过模块的后缀名判断,例如遇到 *.css 的模块,无需任何处理,不再深入遍历。
- 在解析过程中,设置为 external
- js 模块
- 将代码转成 AST,获取其中 import 语句引入的模块,或者正则匹配出所有 import 的模块,然后继续深入遍历这些模块
- html、vue 类型模块
- 需要把这部分 JS 代码提取出来,然后按 JS 模块进行分析处理,继续深入遍历这些模块。这里只需要关心 JS 部分,其他部分不会引入模块。
- 在解析过程中,将这些模块加载成 JS
- 非es module模块,不做处理
- 每个外联 script:src=‘xxx/xxx’;会直接转换为 import 语句,引入外部 script
- 每个内联 script,及直接在script中写内容,其内容将会通过虚拟模块被引入。
- 虚拟模块:是模块的内容并非直接从磁盘中读取,而是编译时生成。多个内联的 script 标签,其内容无法处理成一个 js 文件 (因为可能会有命名冲突等原因),既然无法将多个内联 script,就只能将它们分散成多个虚拟模块,然后分别引入了
- 如:src/main.ts 是磁盘中实际存在的文件,而 virtual-module:D:/project/index.html?id=0 在磁盘中是不存在的,需要借助打包工具(如 esbuild),在编译过程生成。
-
遍历的具体实现
- 用 esbuild 工具打包,因为打包过程本质上也是个深度遍历模块的过程
- 深入遍历模块:正常解析模块(esbuild 默认行为),返回模块的文件真实路径
- 不用继续深度遍历的节点:esbuild 可以在解析过程,指定当前解析的模块为 external
则 esbuild 不再深入解析和加载该模块。 - 最终 dep 对象中收集到的依赖就是依赖扫描的结果
esbuild插件编写
- 每个模块都会经过解析(resolve)和加载(load)的过程:
解析:将模块路径,解析成文件真实的路径。例如 vue,会解析到实际 node_modules 中的 vue 的入口 js 文件 加载:根据解析的路径,读取文件的内容
const plugin = {
name: 'xxx',
setup(build) {
build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
path,
external: true
}))
build.onResolve({ filter: /.*\.less/ }, args => ({
path: args.path,
namespace: 'less',
}))
build.onLoad({ filter: /.*/, namespace: 'less' }, () => {
const raw = fs.readFileSync(path, 'utf-8')
const content =
return {
contents,
loader: 'css'
}
})
}
}
-
通过 onResolve、onLoad 定义解析和加载过程 -
onResolve 的第一个参数为过滤条件,第二个参数为回调函数,解析时调用,返回值可以给模块做标记,如 external、namespace(用于过滤),还需要返回模块的路径 -
onLoad 的第一个参数为过滤条件,第二个参数为回调函数,加载时调用,可以读取文件的内容,然后进行处理,最后返回加载的内容。
源码解析
依赖扫描的入口
import { build } from 'esbuild'
export async function scanImports(config: ResolvedConfig): Promise<{
deps: Record<string, string>
missing: Record<string, string>
}> {
let entries: string[] = await globEntries('**/*.html', config)
const deps: Record<string, string> = {}
const missing: Record<string, string> = {}
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
await Promise.all(
entries.map((entry) =>
build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
plugins: [...plugins, plugin],
...esbuildOptions
})
)
)
return {
deps,
missing
}
}
esbuildScanPlugin
function esbuildScanPlugin(
config: ResolvedConfig,
container: PluginContainer,
depImports: Record<string, string>,
missing: Record<string, string>,
entries: string[]
): Plugin
- esbuild 默认能将模块路径转成真实路径,为什么还要用 container.resolveId ?因为 Vite/Rollup 的插件,也能扩展解析的流程,例如 alias 的能力,我们常常会在项目中用 @ 的别名代表项目的 src 路径。因此不能用 esbuild 原生的解析流程进行解析。
container.resolveId
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模块
build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
path,
external: true
}))
build.onResolve(
{
filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json|wasm)$/
},
({ path }) => ({
path,
external: true
}
)
bare import
build.onResolve(
{
filter: /^[\w@][^:]/
},
async ({ path: id, importer, pluginData }) => {
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
const resolved = await resolve(id, importer, {
custom: {
depScan: { loader: pluginData?.htmlType?.loader }
}
})
if (resolved) {
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
if (resolved.includes('node_modules')) {
depImports[id] = resolved
return {
path,
external: true
}
}
else if (isScannable(resolved)) {
return {
path: path.resolve(resolved)
}
} else {
return {
path,
external: true
}
}
} else {
missing[id] = normalizePath(importer)
}
}
)
html、vue 类型模块
const htmlTypesRE = /\.(html|vue|svelte|astro)$/
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
const resolved = await resolve(path, importer)
if (!resolved) return
if (resolved.includes('node_modules'){
return
}
return {
path: resolved,
namespace: 'html'
}
})
- 加载过程
- 读取文件源码
- 正则匹配出所有的 script 标签,并对每个 script 标签的内容进行处理
- 外部 script,改为用 import 引入
- 内联 script,改为引入虚拟模块,并将对应的虚拟模块的内容缓存到 script 对象。
最后返回转换后的 js
const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
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')
const regex = isHtml ? scriptModuleRE : scriptRE
regex.lastIndex = 0
let js = ''
let scriptId = 0
let match: RegExpExecArray | null
while ((match = regex.exec(raw))) {
const [, openTag, content] = match
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])
if (
type &&
!(
type.includes('javascript') ||
type.includes('ecmascript') ||
type === 'module'
)
) {
continue
}
let loader: Loader = 'js'
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang
} else if (path.endsWith('.astro')) {
loader = 'ts'
}
const srcMatch = openTag.match(srcRE)
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
const key = `${path}?id=${scriptId++}`
scripts[key] = {
loader,
content,
pluginData: {
htmlType: { loader }
}
}
const virtualModulePath = virtualModulePrefix + key
js += `export * from ${virtualModulePath}\n`
}
}
return {
loader: 'js',
contents: js
}
}
)
虚拟模块加载对应的 script 代码
- 直接从 script 对象中,读取之前缓存起来的内容
export const virtualModuleRE = /^virtual-module:.*/
build.onResolve({ filter: virtualModuleRE }, ({ path }) => {
return {
path: path.replace(virtualModulePrefix, ''),
namespace: 'script'
}
})
build.onLoad({ filter: /.*/, namespace: 'script' }, ({ path }) => {
return scripts[path]
})
扫描结果
- 最后获取到的 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"
}
|