理解如何进行依赖扫描和认识一些比较少见却很实用的三方库。
先来分析用户提供的依赖路径
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
字符串写入。