理解如何进行依赖扫描和认识一些比较少见却很实用的三方库。
先来分析用户提供的依赖路径
async function scanDirs() {
if (dirs?.length) {
// 调用完此函数后会将解析的import规则全部放入ctx.dynamicImports中
await unimport.modifyDynamicImports(async (imports) => {
const exports_ = await scanDirExports(dirs, {
filePatterns: ['*.{tsx,jsx,ts,js,mjs,cjs,mts,cts}'],
}) as ImportExtended[]
exports_.forEach(i => i.__source = 'dir')
return modifyDefaultExportsAlias([
...imports.filter((i: ImportExtended) => i.__source !== 'dir'),
...exports_,
], options)
})
}
writeConfigFilesThrottled()
}若传递了dirs属性则会调用来自createUnimport()内提供的modifyDynamicImports方法。
在modifyDynamicImports的callback内,在scanDirExports完成后将得到的exports_都添加了属性__source并打上标记dir,最后返回来自modifyDefaultExportsAlias的执行结果,这里入参会对imports进行过滤, 目的是为了过滤之前已经扫描过的文件。
最后创建将配置文件输出。
接下来进入modifyDynamicImports。
async function modifyDynamicImports (fn: (imports: Import[]) => Thenable<void | Import[]>) {
const result = await fn(ctx.dynamicImports)
if (Array.isArray(result)) {
ctx.dynamicImports = result
}
ctx.invalidate()
}储存了来自fn的返回值,也就是上门modifyDefaultExportsAlias的返回值,若有值则储存在dynamicImports内,然后重置_combinedImports属性(invalidate的内部将_combinedImports = undefined),接下来回到上面定义的fn内。
export async function scanDirExports (dir: string | string[], options?: ScanDirExportsOptions) {
const files = await scanFilesFromDir(dir, options)
const fileExports = await Promise.all(files.map(i => scanExports(i)))
return fileExports.flat()
}在scanDirExports内,将scanFilesFromDir的返回值储存再逐一调用scanExports后进行扁平化数组再返回,继续进入scanFilesFromDir内。
export async function scanFilesFromDir (dir: string | string[], options?: ScanDirExportsOptions) {
const dirs = (Array.isArray(dir) ? dir : [dir]).map(d => normalize(d))
const fileFilter = options?.fileFilter || (() => true)
const filePatterns = options?.filePatterns || ['*.{ts,js,mjs,cjs,mts,cts}']
const result = await Promise.all(
// Do multiple glob searches to persist the order of input dirs
dirs.map(async i => await fg(
// 扫描的路径
[i, ...filePatterns.map(p => join(i, p))],
{
absolute: true, // 以绝对路径返回
cwd: options?.cwd || process.cwd(), // 当前工作路径
onlyFiles: true, // 只会返回扫描的文件,忽略文件夹
followSymbolicLinks: true // 这个配置不是很清楚作用 :(
})
.then(r => r
.map(f => normalize(f))
.sort()
)
)
)
return Array.from(new Set(result.flat())).filter(fileFilter)
}在scanFilesFromDir内,将dir统一转化为数组并将其路径规范化,定义变量fileFilter储存是否传入了fileFilter选项, 定义变量filePatterns储存是否传入了自定义规则,在上面传入的callback中传入了['*.{tsx,jsx,ts,js,mjs,cjs,mts,cts}'],这里和原本的区别就是增加了jsx和tsx的支持。
接下来使用fast-glob来扫描dir下的所有文件,得到返回值后会进行路径规范化、排序、去重、扁平化、过滤后返回。
在vite-react中的dirs配置为dirs: ['src/layouts', 'src/views'],那么此时得到的应为:
[
'your-dir-name/unplugin-auto-import/examples/vite-react/src/layouts/MainLayout.tsx',
'your-dir-name/unplugin-auto-import/examples/vite-react/src/views/PageA.tsx',
'your-dir-name/unplugin-auto-import/examples/vite-react/src/views/PageB.tsx'
]在这里完成了dirs内提供的所有文件的路径扫描,接下来来看一下scanExports内。
export async function scanExports (filepath: string, seen = new Set<string>()): Promise<Import[]> {
if (seen.has(filepath)) {
// eslint-disable-next-line no-console
console.warn(`[unimport] "${filepath}" is already scanned, skipping`)
return []
}
seen.add(filepath)
const imports: Import[] = []
const code = await readFile(filepath, 'utf-8')
const exports = findExports(code)
const defaultExport = exports.find(i => i.type === 'default')
if (defaultExport) {
let name = parsePath(filepath).name
if (name === 'index') {
name = parsePath(filepath.split('/').slice(0, -1).join('/')).name
}
// Only camel-case name if it contains separators by which scule would split,
// see STR_SPLITTERS: https://github.com/unjs/scule/blob/main/src/index.ts
const as = /[-_.]/.test(name) ? camelCase(name) : name
imports.push({ name: 'default', as, from: filepath })
}
for (const exp of exports) {
if (exp.type === 'named') {
for (const name of exp.names) {
imports.push({ name, as: name, from: filepath })
}
} else if (exp.type === 'declaration') {
if (exp.name) {
imports.push({ name: exp.name, as: exp.name, from: filepath })
}
} else if (exp.type === 'star' && exp.specifier) {
if (exp.name) {
// export * as foo from './foo'
imports.push({ name: exp.name, as: exp.name, from: filepath })
} else {
// export * from './foo', scan deeper
const subfile = exp.specifier
let subfilepath = resolve(dirname(filepath), subfile)
if (!extname(subfilepath)) {
for (const ext of FileExtensionLookup) {
if (existsSync(`${subfilepath}${ext}`)) {
subfilepath = `${subfilepath}${ext}`
break
} else if (existsSync(`${subfilepath}/index${ext}`)) {
subfilepath = `${subfilepath}/index${ext}`
break
}
}
}
if (!existsSync(subfilepath)) {
// eslint-disable-next-line no-console
console.warn(`[unimport] failed to resolve "${subfilepath}", skip scanning`)
continue
}
imports.push(...await scanExports(subfilepath, seen))
}
}
}
return imports
}在scanExports中,内部定义了一个new Set(),用于缓存重复出现的文件路径, 接下来开始使用readFile读取其文件内容并使用mlly这个库中的findExports方法分析其静态导出关系。
在example中能得到的数据是:
{
type: 'default',
name: 'default',
code: 'export default ',
start: 252,
end: 267,
names: [ 'default' ]
}获得其导出关系后,如果其导出类型为default(export default), 则会解析这个文件,如果它的名字为index则会使用它的上一级目录名作为名字,比如layouts/index.ts则会以layouts作为变量name, 然后将文件名统一转化为驼峰的形式后放入imports内,如属性name为default,属性as为layout。
imports内的数据结构
type Imports = {
// 导出的名称
name: string
// 别名,之后会用得到
as: string
// 对应路径
from: string
}[]接下来开始循环其导出关系,每一个不同的type会进行相应的处理。
named时,如export { bar, baz },会逐个放入imports中,属性name为bar、baz,属性as为bar、baz。declaration时,如export const foo,属性name为foo,属性as为foo。star并且存在specifier: export * as foo from 'bar'时,属性name为foo,属性as为foo。export * from 'bar'时,则会根据导出语句的路径寻找该文件,接下来会查找该文件是否有无扩展名(因为有可能是路径,也有可能是没有扩展名的文件), 若不存在扩展名时,从定义好的静态扩展名内拼接其路径查找是否存在此文件,不存在会去判断文件是否可能为index(因为导入路径可以是layout/index => layout), 都不存在则会报一个警告,最后走一个递归调用放入imports中。至此,scanDirExports内的所有操作就完成了,现在回到callback中。
function modifyDefaultExportsAlias(imports: ImportExtended[], options: Options): Import[] {
if (options.defaultExportByFilename) {
imports.forEach((i) => {
if (i.name === 'default')
i.as = i.from.split('/').pop()?.split('.')?.shift() ?? i.as
})
}
return imports as Import[]
}这个函数的作用是根据选项defaultExportByFilename判断是否需要将文件名作为默认导出的名称。
比如新增一个Index.tsx,导入并默认导出MainLayout.tsx,当配置了defaultExportByFilename时,这里的as将使用Index,反之为MainLayout。
至此,dirs内的动态依赖分析关系完成,接下来进入writeConfigFilesThrottled。
let lastDTS: string | undefined
let lastESLint: string | undefined
async function writeConfigFiles() {
const promises: any[] = []
if (dts) {
promises.push(
generateDTS(dts).then((content) => {
if (content !== lastDTS) {
lastDTS = content
return writeFile(dts, content)
}
}),
)
}
if (eslintrc.enabled && eslintrc.filepath) {
promises.push(
generateESLint().then((content) => {
content = `${content}\n`
if (content.trim() !== lastESLint?.trim()) {
lastESLint = content
return writeFile(eslintrc.filepath!, content)
}
}),
)
}
return Promise.all(promises)
}这里会分别对dts和eslint的生成的文件内容进行新旧比对,起到一个优化作用,先来看一下dts的生成。
async function generateDTS(file: string) {
await importsPromise
const dir = dirname(file)
const originalContent = existsSync(file) ? await fs.readFile(file, 'utf-8') : ''
const originalDTS = parseDTS(originalContent)
const currentContent = await unimport.generateTypeDeclarations({
resolvePath: (i) => {
if (i.from.startsWith('.') || isAbsolute(i.from)) {
const related = slash(relative(dir, i.from).replace(/\.ts(x)?$/, ''))
return !related.startsWith('.')
? `./${related}`
: related
}
return i.from
},
})
const currentDTS = parseDTS(currentContent)!
if (originalDTS) {
Object.keys(currentDTS).forEach((key) => {
originalDTS[key] = currentDTS[key]
})
const dtsList = Object.keys(originalDTS).sort().map(k => ` ${k}: ${originalDTS[k]}`)
return currentContent.replace(dtsReg, () => `declare global {\n${dtsList.join('\n')}\n}`)
}
return currentContent
}在函数开始执行时,先等待了importsPromise执行完毕后才继续,先来看这里面的实现。
const importsPromise = flattenImports(options.imports)
.then((imports) => {
if (!imports.length && !resolvers.length && !dirs?.length)
console.warn('[auto-import] plugin installed but no imports has defined, see https://github.com/antfu/unplugin-auto-import#configurations for configurations')
options.ignore?.forEach((name) => {
const i = imports.find(i => i.as === name)
if (i)
i.disabled = true
})
return unimport.getInternalContext().replaceImports(imports)
})在importsPromise内先调用了flattenImports,将options.imports作为参数传入, 在vite-react/vite.config.ts文件内可以看到传入了react、react-router-dom、react-i18next、ahooks,接下来是扫描并分析静态依赖关系,看一下flattenImports的实现。
export async function flattenImports (map: Options['imports']): Promise<Import[]> {
const promises = await Promise.all(toArray(map)
.map(async (definition) => {
if (typeof definition === 'string') {
if (!presets[definition])
throw new Error(`[auto-import] preset ${ definition } not found`)
const preset = presets[definition]
definition = typeof preset === 'function' ? preset() : preset
}
if ('from' in definition && 'imports' in definition) {
return await resolvePreset(definition as InlinePreset)
} else {
const resolved: Import[] = []
for (const mod of Object.keys(definition)) {
for (const id of definition[mod]) {
const meta = {
from: mod
} as Import
if (Array.isArray(id)) {
meta.name = id[0]
meta.as = id[1]
} else {
meta.name = id
meta.as = id
}
resolved.push(meta)
}
}
return resolved
}
}))
return promises.flat()
}首先将参数map统一转化为了array类型后进行map循环,接下来有三个if分支判断:
['react'])时,从定义的静态依赖映射内寻找,不存在则直接报错,若为function则获取其函数返回值。from和imports时(也就是{ from: 'react', imports: ['useState'] })调用resolvePreset,其实现稍后在看。{ react: ['useState'] } 或 { react: [['useState', 'useMyState']] })时,取出它们的key并循环其value, 创建一个对象,默认属性from在这里的值为react,然后根据value内的类型转为不同的形式。这里数组和非数组的区别
当为数组时,最终的样子是:
import { useState as useMyState } from 'react'非数组则为:
import { useState } from 'react'最后返回转化完静态依赖后的数据,在then中,将转化完成的数据根据options.ignore属性配置的内容对对应的依赖名称过滤, 这里的过滤只是加了一个disabled属性,然后返回replaceImports的执行结果。
async function replaceImports (imports: UnimportOptions['imports']) {
ctx.staticImports = [ ...(imports || []) ].filter(Boolean)
ctx.invalidate()
await resolvePromise
return updateImports()
}将分析完毕的依赖浅拷贝一份并过滤掉不符合规则的数据后,等待resolvePromise执行完毕,最后返回updateImports的结果。
这里的resolvePromise是没有意义的,因为内部代码会对presets属性做一些操作,在createContext时presets为空,所以不会有任何操作。
updateImports的作用是更新动态依赖与静态依赖的数据,这里只需知道其作用。
现在回到generateDTS内,首先会去查找dts给的路径下的文件是否存在,若存在则直接读取文件内容并解析。
const multilineCommentsRE = /\/\*.*?\*\//gms
const singlelineCommentsRE = /\/\/.*$/gm
const dtsReg = /declare\s+global\s*{(.*?)}/s
function parseDTS(dts: string) {
dts = dts
.replace(multilineCommentsRE, '')
.replace(singlelineCommentsRE, '')
const code = dts.match(dtsReg)?.[0]
if (!code)
return
return Object.fromEntries(Array.from(code.matchAll(/['"]?(const\s*[^\s'"]+)['"]?\s*:\s*(.+?)[,;\r\n]/g)).map(i => [i[1], i[2]]))
}在parseDTS函数前,声明了三个正则表达式,前两个的作用删除文件内的单行和多行注释,防止出现其它情况,第三个用于匹配声明文件内的declare global { * }字符, 当匹配成功时,使用另一个正则表达式来拆解成key/value形式后返回
TIP
当文件内容为:
declare global {
const createRef: typeof import('react')['createRef']
}根据定义的正则表达式并用map可以拆解为['const createRef','typeof import('react')['createRef']']
然后用Object.fromEntries方法转化为{ 'const createRef': 'typeof import('react')['createRef']' }
接下来使用了unimport内的generateTypeDeclarations方法
async function generateTypeDeclarations (options?: TypeDeclarationOptions) {
const opts: TypeDeclarationOptions = {
resolvePath: i => i.from,
...options
}
const {
typeReExports = true
} = opts
// 获取储存的依赖数据
const imports = await ctx.getImports()
let dts = toTypeDeclarationFile(imports.filter(i => !i.type), opts)
const typeOnly = imports.filter(i => i.type)
if (typeReExports && typeOnly.length) {
dts += '\n' + toTypeReExports(typeOnly, opts)
}
for (const addon of ctx.addons) {
dts = await addon.declaration?.call(ctx, dts, opts) ?? dts
}
return dts
}在这里会分别进行依赖的类型生成(toTypeDeclarationFile())和导出类型的生成(toTypeReExports()), 然后下面对ctx.addons进行循环也就是使用用户传递的插件,由起步里的createUnimport内可以看到传递了declaration的自定义生成规则,最后导出生成的结果, 所以dts前的一串注释就是这么个由来。
回到generateDTS内,内部保存了每次生成的新旧值,新生成的类型会替换旧生成的类型数据,最后返回生成的数据并根据新旧内容决定是否输出文件。
至此就完成了dts文件的输出过程。
接下来回到writeFile中来看一下eslint规则的生成。
async function parseESLint() {
const configStr = existsSync(eslintrc.filepath!) ? await fs.readFile(eslintrc.filepath!, 'utf-8') : ''
const config = JSON.parse(configStr || '{ "globals": {} }')
return config.globals as Record<string, ESLintGlobalsPropValue>
}
async function generateESLint () {
return generateESLintConfigs(await unimport.getImports(), eslintrc, await parseESLint())
}
export function generateESLintConfigs(
imports: Import[],
eslintrc: ESLintrc,
globals: Record<string, ESLintGlobalsPropValue> = {},
) {
const eslintConfigs = { globals }
imports
.map(i => i.as ?? i.name)
.filter(Boolean)
.sort()
.forEach((name) => {
eslintConfigs.globals[name] = eslintrc.globalsPropValue || true
})
const jsonBody = JSON.stringify(eslintConfigs, null, 2)
return jsonBody
}这一部分则比较简单,根据文件路径读取json文件,然后解析好json字符串后取其gobals属性,写入时根据imports内的数据进行过滤、排序后转为json字符串写入。
理解如何进行依赖扫描和认识一些比较少见却很实用的三方库。
先来分析用户提供的依赖路径
async function scanDirs() {
if (dirs?.length) {
// 调用完此函数后会将解析的import规则全部放入ctx.dynamicImports中
await unimport.modifyDynamicImports(async (imports) => {
const exports_ = await scanDirExports(dirs, {
filePatterns: ['*.{tsx,jsx,ts,js,mjs,cjs,mts,cts}'],
}) as ImportExtended[]
exports_.forEach(i => i.__source = 'dir')
return modifyDefaultExportsAlias([
...imports.filter((i: ImportExtended) => i.__source !== 'dir'),
...exports_,
], options)
})
}
writeConfigFilesThrottled()
}若传递了dirs属性则会调用来自createUnimport()内提供的modifyDynamicImports方法。
在modifyDynamicImports的callback内,在scanDirExports完成后将得到的exports_都添加了属性__source并打上标记dir,最后返回来自modifyDefaultExportsAlias的执行结果,这里入参会对imports进行过滤, 目的是为了过滤之前已经扫描过的文件。
最后创建将配置文件输出。
接下来进入modifyDynamicImports。
async function modifyDynamicImports (fn: (imports: Import[]) => Thenable<void | Import[]>) {
const result = await fn(ctx.dynamicImports)
if (Array.isArray(result)) {
ctx.dynamicImports = result
}
ctx.invalidate()
}储存了来自fn的返回值,也就是上门modifyDefaultExportsAlias的返回值,若有值则储存在dynamicImports内,然后重置_combinedImports属性(invalidate的内部将_combinedImports = undefined),接下来回到上面定义的fn内。
export async function scanDirExports (dir: string | string[], options?: ScanDirExportsOptions) {
const files = await scanFilesFromDir(dir, options)
const fileExports = await Promise.all(files.map(i => scanExports(i)))
return fileExports.flat()
}在scanDirExports内,将scanFilesFromDir的返回值储存再逐一调用scanExports后进行扁平化数组再返回,继续进入scanFilesFromDir内。
export async function scanFilesFromDir (dir: string | string[], options?: ScanDirExportsOptions) {
const dirs = (Array.isArray(dir) ? dir : [dir]).map(d => normalize(d))
const fileFilter = options?.fileFilter || (() => true)
const filePatterns = options?.filePatterns || ['*.{ts,js,mjs,cjs,mts,cts}']
const result = await Promise.all(
// Do multiple glob searches to persist the order of input dirs
dirs.map(async i => await fg(
// 扫描的路径
[i, ...filePatterns.map(p => join(i, p))],
{
absolute: true, // 以绝对路径返回
cwd: options?.cwd || process.cwd(), // 当前工作路径
onlyFiles: true, // 只会返回扫描的文件,忽略文件夹
followSymbolicLinks: true // 这个配置不是很清楚作用 :(
})
.then(r => r
.map(f => normalize(f))
.sort()
)
)
)
return Array.from(new Set(result.flat())).filter(fileFilter)
}在scanFilesFromDir内,将dir统一转化为数组并将其路径规范化,定义变量fileFilter储存是否传入了fileFilter选项, 定义变量filePatterns储存是否传入了自定义规则,在上面传入的callback中传入了['*.{tsx,jsx,ts,js,mjs,cjs,mts,cts}'],这里和原本的区别就是增加了jsx和tsx的支持。
接下来使用fast-glob来扫描dir下的所有文件,得到返回值后会进行路径规范化、排序、去重、扁平化、过滤后返回。
在vite-react中的dirs配置为dirs: ['src/layouts', 'src/views'],那么此时得到的应为:
[
'your-dir-name/unplugin-auto-import/examples/vite-react/src/layouts/MainLayout.tsx',
'your-dir-name/unplugin-auto-import/examples/vite-react/src/views/PageA.tsx',
'your-dir-name/unplugin-auto-import/examples/vite-react/src/views/PageB.tsx'
]在这里完成了dirs内提供的所有文件的路径扫描,接下来来看一下scanExports内。
export async function scanExports (filepath: string, seen = new Set<string>()): Promise<Import[]> {
if (seen.has(filepath)) {
// eslint-disable-next-line no-console
console.warn(`[unimport] "${filepath}" is already scanned, skipping`)
return []
}
seen.add(filepath)
const imports: Import[] = []
const code = await readFile(filepath, 'utf-8')
const exports = findExports(code)
const defaultExport = exports.find(i => i.type === 'default')
if (defaultExport) {
let name = parsePath(filepath).name
if (name === 'index') {
name = parsePath(filepath.split('/').slice(0, -1).join('/')).name
}
// Only camel-case name if it contains separators by which scule would split,
// see STR_SPLITTERS: https://github.com/unjs/scule/blob/main/src/index.ts
const as = /[-_.]/.test(name) ? camelCase(name) : name
imports.push({ name: 'default', as, from: filepath })
}
for (const exp of exports) {
if (exp.type === 'named') {
for (const name of exp.names) {
imports.push({ name, as: name, from: filepath })
}
} else if (exp.type === 'declaration') {
if (exp.name) {
imports.push({ name: exp.name, as: exp.name, from: filepath })
}
} else if (exp.type === 'star' && exp.specifier) {
if (exp.name) {
// export * as foo from './foo'
imports.push({ name: exp.name, as: exp.name, from: filepath })
} else {
// export * from './foo', scan deeper
const subfile = exp.specifier
let subfilepath = resolve(dirname(filepath), subfile)
if (!extname(subfilepath)) {
for (const ext of FileExtensionLookup) {
if (existsSync(`${subfilepath}${ext}`)) {
subfilepath = `${subfilepath}${ext}`
break
} else if (existsSync(`${subfilepath}/index${ext}`)) {
subfilepath = `${subfilepath}/index${ext}`
break
}
}
}
if (!existsSync(subfilepath)) {
// eslint-disable-next-line no-console
console.warn(`[unimport] failed to resolve "${subfilepath}", skip scanning`)
continue
}
imports.push(...await scanExports(subfilepath, seen))
}
}
}
return imports
}在scanExports中,内部定义了一个new Set(),用于缓存重复出现的文件路径, 接下来开始使用readFile读取其文件内容并使用mlly这个库中的findExports方法分析其静态导出关系。
在example中能得到的数据是:
{
type: 'default',
name: 'default',
code: 'export default ',
start: 252,
end: 267,
names: [ 'default' ]
}获得其导出关系后,如果其导出类型为default(export default), 则会解析这个文件,如果它的名字为index则会使用它的上一级目录名作为名字,比如layouts/index.ts则会以layouts作为变量name, 然后将文件名统一转化为驼峰的形式后放入imports内,如属性name为default,属性as为layout。
imports内的数据结构
type Imports = {
// 导出的名称
name: string
// 别名,之后会用得到
as: string
// 对应路径
from: string
}[]接下来开始循环其导出关系,每一个不同的type会进行相应的处理。
named时,如export { bar, baz },会逐个放入imports中,属性name为bar、baz,属性as为bar、baz。declaration时,如export const foo,属性name为foo,属性as为foo。star并且存在specifier: export * as foo from 'bar'时,属性name为foo,属性as为foo。export * from 'bar'时,则会根据导出语句的路径寻找该文件,接下来会查找该文件是否有无扩展名(因为有可能是路径,也有可能是没有扩展名的文件), 若不存在扩展名时,从定义好的静态扩展名内拼接其路径查找是否存在此文件,不存在会去判断文件是否可能为index(因为导入路径可以是layout/index => layout), 都不存在则会报一个警告,最后走一个递归调用放入imports中。至此,scanDirExports内的所有操作就完成了,现在回到callback中。
function modifyDefaultExportsAlias(imports: ImportExtended[], options: Options): Import[] {
if (options.defaultExportByFilename) {
imports.forEach((i) => {
if (i.name === 'default')
i.as = i.from.split('/').pop()?.split('.')?.shift() ?? i.as
})
}
return imports as Import[]
}这个函数的作用是根据选项defaultExportByFilename判断是否需要将文件名作为默认导出的名称。
比如新增一个Index.tsx,导入并默认导出MainLayout.tsx,当配置了defaultExportByFilename时,这里的as将使用Index,反之为MainLayout。
至此,dirs内的动态依赖分析关系完成,接下来进入writeConfigFilesThrottled。
let lastDTS: string | undefined
let lastESLint: string | undefined
async function writeConfigFiles() {
const promises: any[] = []
if (dts) {
promises.push(
generateDTS(dts).then((content) => {
if (content !== lastDTS) {
lastDTS = content
return writeFile(dts, content)
}
}),
)
}
if (eslintrc.enabled && eslintrc.filepath) {
promises.push(
generateESLint().then((content) => {
content = `${content}\n`
if (content.trim() !== lastESLint?.trim()) {
lastESLint = content
return writeFile(eslintrc.filepath!, content)
}
}),
)
}
return Promise.all(promises)
}这里会分别对dts和eslint的生成的文件内容进行新旧比对,起到一个优化作用,先来看一下dts的生成。
async function generateDTS(file: string) {
await importsPromise
const dir = dirname(file)
const originalContent = existsSync(file) ? await fs.readFile(file, 'utf-8') : ''
const originalDTS = parseDTS(originalContent)
const currentContent = await unimport.generateTypeDeclarations({
resolvePath: (i) => {
if (i.from.startsWith('.') || isAbsolute(i.from)) {
const related = slash(relative(dir, i.from).replace(/\.ts(x)?$/, ''))
return !related.startsWith('.')
? `./${related}`
: related
}
return i.from
},
})
const currentDTS = parseDTS(currentContent)!
if (originalDTS) {
Object.keys(currentDTS).forEach((key) => {
originalDTS[key] = currentDTS[key]
})
const dtsList = Object.keys(originalDTS).sort().map(k => ` ${k}: ${originalDTS[k]}`)
return currentContent.replace(dtsReg, () => `declare global {\n${dtsList.join('\n')}\n}`)
}
return currentContent
}在函数开始执行时,先等待了importsPromise执行完毕后才继续,先来看这里面的实现。
const importsPromise = flattenImports(options.imports)
.then((imports) => {
if (!imports.length && !resolvers.length && !dirs?.length)
console.warn('[auto-import] plugin installed but no imports has defined, see https://github.com/antfu/unplugin-auto-import#configurations for configurations')
options.ignore?.forEach((name) => {
const i = imports.find(i => i.as === name)
if (i)
i.disabled = true
})
return unimport.getInternalContext().replaceImports(imports)
})在importsPromise内先调用了flattenImports,将options.imports作为参数传入, 在vite-react/vite.config.ts文件内可以看到传入了react、react-router-dom、react-i18next、ahooks,接下来是扫描并分析静态依赖关系,看一下flattenImports的实现。
export async function flattenImports (map: Options['imports']): Promise<Import[]> {
const promises = await Promise.all(toArray(map)
.map(async (definition) => {
if (typeof definition === 'string') {
if (!presets[definition])
throw new Error(`[auto-import] preset ${ definition } not found`)
const preset = presets[definition]
definition = typeof preset === 'function' ? preset() : preset
}
if ('from' in definition && 'imports' in definition) {
return await resolvePreset(definition as InlinePreset)
} else {
const resolved: Import[] = []
for (const mod of Object.keys(definition)) {
for (const id of definition[mod]) {
const meta = {
from: mod
} as Import
if (Array.isArray(id)) {
meta.name = id[0]
meta.as = id[1]
} else {
meta.name = id
meta.as = id
}
resolved.push(meta)
}
}
return resolved
}
}))
return promises.flat()
}首先将参数map统一转化为了array类型后进行map循环,接下来有三个if分支判断:
['react'])时,从定义的静态依赖映射内寻找,不存在则直接报错,若为function则获取其函数返回值。from和imports时(也就是{ from: 'react', imports: ['useState'] })调用resolvePreset,其实现稍后在看。{ react: ['useState'] } 或 { react: [['useState', 'useMyState']] })时,取出它们的key并循环其value, 创建一个对象,默认属性from在这里的值为react,然后根据value内的类型转为不同的形式。这里数组和非数组的区别
当为数组时,最终的样子是:
import { useState as useMyState } from 'react'非数组则为:
import { useState } from 'react'最后返回转化完静态依赖后的数据,在then中,将转化完成的数据根据options.ignore属性配置的内容对对应的依赖名称过滤, 这里的过滤只是加了一个disabled属性,然后返回replaceImports的执行结果。
async function replaceImports (imports: UnimportOptions['imports']) {
ctx.staticImports = [ ...(imports || []) ].filter(Boolean)
ctx.invalidate()
await resolvePromise
return updateImports()
}将分析完毕的依赖浅拷贝一份并过滤掉不符合规则的数据后,等待resolvePromise执行完毕,最后返回updateImports的结果。
这里的resolvePromise是没有意义的,因为内部代码会对presets属性做一些操作,在createContext时presets为空,所以不会有任何操作。
updateImports的作用是更新动态依赖与静态依赖的数据,这里只需知道其作用。
现在回到generateDTS内,首先会去查找dts给的路径下的文件是否存在,若存在则直接读取文件内容并解析。
const multilineCommentsRE = /\/\*.*?\*\//gms
const singlelineCommentsRE = /\/\/.*$/gm
const dtsReg = /declare\s+global\s*{(.*?)}/s
function parseDTS(dts: string) {
dts = dts
.replace(multilineCommentsRE, '')
.replace(singlelineCommentsRE, '')
const code = dts.match(dtsReg)?.[0]
if (!code)
return
return Object.fromEntries(Array.from(code.matchAll(/['"]?(const\s*[^\s'"]+)['"]?\s*:\s*(.+?)[,;\r\n]/g)).map(i => [i[1], i[2]]))
}在parseDTS函数前,声明了三个正则表达式,前两个的作用删除文件内的单行和多行注释,防止出现其它情况,第三个用于匹配声明文件内的declare global { * }字符, 当匹配成功时,使用另一个正则表达式来拆解成key/value形式后返回
TIP
当文件内容为:
declare global {
const createRef: typeof import('react')['createRef']
}根据定义的正则表达式并用map可以拆解为['const createRef','typeof import('react')['createRef']']
然后用Object.fromEntries方法转化为{ 'const createRef': 'typeof import('react')['createRef']' }
接下来使用了unimport内的generateTypeDeclarations方法
async function generateTypeDeclarations (options?: TypeDeclarationOptions) {
const opts: TypeDeclarationOptions = {
resolvePath: i => i.from,
...options
}
const {
typeReExports = true
} = opts
// 获取储存的依赖数据
const imports = await ctx.getImports()
let dts = toTypeDeclarationFile(imports.filter(i => !i.type), opts)
const typeOnly = imports.filter(i => i.type)
if (typeReExports && typeOnly.length) {
dts += '\n' + toTypeReExports(typeOnly, opts)
}
for (const addon of ctx.addons) {
dts = await addon.declaration?.call(ctx, dts, opts) ?? dts
}
return dts
}在这里会分别进行依赖的类型生成(toTypeDeclarationFile())和导出类型的生成(toTypeReExports()), 然后下面对ctx.addons进行循环也就是使用用户传递的插件,由起步里的createUnimport内可以看到传递了declaration的自定义生成规则,最后导出生成的结果, 所以dts前的一串注释就是这么个由来。
回到generateDTS内,内部保存了每次生成的新旧值,新生成的类型会替换旧生成的类型数据,最后返回生成的数据并根据新旧内容决定是否输出文件。
至此就完成了dts文件的输出过程。
接下来回到writeFile中来看一下eslint规则的生成。
async function parseESLint() {
const configStr = existsSync(eslintrc.filepath!) ? await fs.readFile(eslintrc.filepath!, 'utf-8') : ''
const config = JSON.parse(configStr || '{ "globals": {} }')
return config.globals as Record<string, ESLintGlobalsPropValue>
}
async function generateESLint () {
return generateESLintConfigs(await unimport.getImports(), eslintrc, await parseESLint())
}
export function generateESLintConfigs(
imports: Import[],
eslintrc: ESLintrc,
globals: Record<string, ESLintGlobalsPropValue> = {},
) {
const eslintConfigs = { globals }
imports
.map(i => i.as ?? i.name)
.filter(Boolean)
.sort()
.forEach((name) => {
eslintConfigs.globals[name] = eslintrc.globalsPropValue || true
})
const jsonBody = JSON.stringify(eslintConfigs, null, 2)
return jsonBody
}这一部分则比较简单,根据文件路径读取json文件,然后解析好json字符串后取其gobals属性,写入时根据imports内的数据进行过滤、排序后转为json字符串写入。