// vue 是 bare import
import vue from "vue"
import xxx from "vue/xxx"
// 以下不是裸依赖,用路径去访问的模块,不是 bare import
import foo from "./foo.ts"
import foo1 from "/foo.ts"
当前节点不需要继续深度的情况
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(用于过滤),还需要返回模块的路径
onLoad 的第一个参数为过滤条件,第二个参数为回调函数,加载时调用,可以读取文件的内容,然后进行处理,最后返回加载的内容。
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
}
}
esbuildScanPlugin
function esbuildScanPlugin(
config: ResolvedConfig,
container: PluginContainer,
depImports: Record<string, string>,
missing: Record<string, string>,
entries: string[]
): Plugin
//config:Vite 的解析好的用户配置
//container:这里只会用到 container.resolveId 的方法,这个方法能将模块路径转成真实路径。
//例如 vue 转成 xxx/node_modules/dist/vue.esm-bundler.js。
//depImports:用于存储扫描到的依赖对象,插件执行过程中会被修改
//missing:用于存储缺少的依赖的对象,插件执行过程中会被修改
//entries:存储所有入口文件的数组
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
}
// 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 无关的模块
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 模块的文件,如import 'xx.less'
else if (isScannable(resolved)) {
// 真实路径不在 node_modules 中,则证明是 monorepo,实际上代码还是在用户的目录中
// 是用户自己写的代码,不应该 external
return {
path: path.resolve(resolved)
}
} else {
// 其他模块不可扫描,直接忽略,external
return {
path,
external: true
}
}
} else {
// 解析不到依赖,则记录缺少的依赖
missing[id] = normalizePath(importer)
}
}
)
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'
}
})
// 正则,匹配例子:
const scriptModuleRE = /(
export const scriptRE = /(
// scriptRE:
// 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: 它的值的例子:
// 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 属性
//可能有三种情况:src="xxx"、src='xxx'、src=xxx
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
}
}
)
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"
}