简单的聊聊 tsup 的源码
前言
本章来简单的聊聊 tsup
的源码。
每个人的思维不同,可能我认为难的地方读者觉得简单,亦或反之。鄙人技术能力有限,讲的不好的地方望读者指点指点。
有些细节的地方我会略过,只讲整体的功能。
所以本文不是逐行源码解析,本文不是逐行源码解析,本文不是逐行源码解析。
tips: 源码版本 v6.6.3,源码版本最好与我保持一致!
cli 入口
克隆下 tsup
项目安装下依赖。
打开 package.json
,可以看到 "tsup": "dist/cli-default.js"
。
那么就说明主入口就是cli-default.js
,cli-default.js
中使用 cli-main.js
,现阶段目标 cli-main.ts
。
tsup
使用 cac
注册的的 cli
,.options('
是用来接受 shell
命令行传递的配置。
重点在 const { build } = await import('.')
,这里告诉我们 index.ts
是构建的入口文件。
构建入口
打开 index.ts
看到密密麻麻的代码,没事,一个个去拆解。
index.ts
有4个方法,先逐个方法击破。
-
defineConfig
写tsup
配置项时可以用它定义,跟vite
的defineConfig
“用意”一致(其目的为了代码提示) -
killProcess
单看名字是杀进程的,只是暂时不清楚用在哪里。 -
normalizeOptions
处理配置参数的,规范一下配置项,还不清楚在哪里使用。 -
build
核心功能用来构建代码,该方法会被cli
调用。
defineConfig
和 killProcess
看名字大概知道干啥的了,那么就来解析 normalizeOptions
和 build
把。
build 方法
tips: 遇到比较长的代码我就不贴全部代码。
export async function build(_options: Options) {
const config =
_options.config === false
? {}
: await loadTsupConfig(
process.cwd(),
_options.config === true ? undefined : _options.config
)
const configData =
typeof config.data === 'function'
? await config.data(_options)
: config.data
await Promise.all(
[...(Array.isArray(configData) ? configData : [configData])].map(
async (item) => {
const logger = createLogger(item?.name)
// 使用 normalizeOptions
const options = await normalizeOptions(logger, item, _options)
...
}
build
方法会调用 loadTsupConfig
寻找配置文件,先去查找配置文件绝对路径,然后通过 bundleRequire
读取结果,返回出去。
拿到配置 data
后,如果是函数则将 _options
传递进去。
下一步遍历 configData
,调用上文说的 normalizeOptions
用来处理配置参数。
倒腾完配置项,接下来就是需要做构建了,以及不知道 killProcess
在哪里使用,往下继续看把……
构建部分会执行两个方法 dtsTask
和 mainTasks
。
dtsTask
看名字就知道它用来处理 .d.ts
的,先看看这张图,大概熟悉下 dtsTask
执行的流程。
dtsTask
方法用 worker
开辟新的进程调用 rollup.js
(对应 rollup.ts
),worker.postMessage
传递的消息由 rollup
中 parentPort
接收。
当 parentPort
接收到消息开始执行 startRollup
方法构建 d.ts
文件。
startRollup
执行 getRollupConfig
方法获取构建需要的配置。获取到配置文件后判断是否开启了 watch
,开不开启 watch
最终的结果都是一样的,都会生成构建结果 .d.ts
文件。
watchRollup
与 runRollup
具体的内部源码我就不赘述了,感兴趣的朋友可以自行查看,我大概讲讲两个方法的不同处。
async function watchRollup(options: {
inputConfig: InputOptions
outputConfig: OutputOptions
}) {
const { watch } = await import('rollup')
watch({
...options.inputConfig,
plugins: options.inputConfig.plugins,
output: options.outputConfig,
}).on('event', (event) => {
if (event.code === 'START') {
logger.info('dts', 'Build start')
} else if (event.code === 'BUNDLE_END') {
logger.success('dts', `⚡️ Build success in ${event.duration}ms`)
parentPort?.postMessage('success')
} else if (event.code === 'ERROR') {
logger.error('dts', 'Build failed')
handleError(event.error)
}
})
}
watchRollup
方法调用 rollup
提供的 watch
方法,该方法会自动监听文件的变化,并触发构建。
async function runRollup(options: RollupConfig) {
const { rollup } = await import('rollup')
try {
const start = Date.now()
const getDuration = () => {
return `${Math.floor(Date.now() - start)}ms`
}
logger.info('dts', 'Build start')
const bundle = await rollup(options.inputConfig)
const result = await bundle.write(options.outputConfig)
logger.success('dts', `⚡️ Build success in ${getDuration()}`)
reportSize(
logger,
'dts',
result.output.reduce((res, info) => {
const name = path.relative(
process.cwd(),
path.join(options.outputConfig.dir || '.', info.fileName)
)
return {
...res,
[name]: info.type === 'chunk' ? info.code.length : info.source.length,
}
}, {})
)
} catch (error) {
handleError(error)
logger.error('dts', 'Build error')
}
}
runRollup
方法则调用 rollup
中 rollup
方法(没错就是叫这个名字!),构建完成后会去调用 reportSize
在控制台打印构建相关信息。
至于为什么要使用 Worker
呢? 因为 ts
类型处理真的慢……
源码构建才花了 21 毫秒,.d.ts
花了 497 毫秒。开辟 worker
用来处理 .d.ts
不要影响到源码的构建。
dtsTask 小结
-
dtsTask
开辟一个worker
,调用rollup
构建d.ts
类型。 -
getRollupConfig
根据外部传递进来的配置项整理出rollup
所需要的配置。 -
在
startRollup
方法内部判断了watch
是否传递,如果传递则用rollup
不同的api
来构建d.ts
,最后通过parentPort
发送通知 or 关闭。
mainTasks
mainTasks
负责源码的构建了。
// mianTasks 整体大概结构
const mainTasks = async () => {
if (!options.dts?.only) {
let onSuccessProcess: ChildProcess | undefined
let onSuccessCleanup: (() => any) | undefined | void
// buildDependencies 与 depsHash 可以留意一下!
const buildDependencies: Set<string> = new Set()
let depsHash = await getAllDepsHash(process.cwd())
const doOnSuccessCleanup = async () => {}
const debouncedBuildAll = debouncePromise()
const buildAll = async () => {}
const startWatcher = async () => {}
await buildAll()
copyPublicDir(options.publicDir, options.outDir)
startWatcher()
}
}
可以看到先执行的是 buildAll
这个方法,看下面这张图大概清楚做了什么事情。
buildAll
看样子就是构建的核心了,
const buildAll = async () => {
await doOnSuccessCleanup()
const previousBuildDependencies = new Set(buildDependencies)
buildDependencies.clear()
if (options.clean) {
const extraPatterns = Array.isArray(options.clean)
? options.clean
: []
if (options.dts) {
extraPatterns.unshift('!**/*.d.ts');
}
await removeFiles(
['**/*', ...extraPatterns],
options.outDir
)
logger.info('CLI', 'Cleaning output folder')
}
const css: Map<string, string> = new Map()
await Promise.all([
...options.format.map(async (format, index) => {
const pluginContainer = new PluginContainer([
shebang(),
...(options.plugins || []),
treeShakingPlugin({
treeshake: options.treeshake,
name: options.globalName,
silent: options.silent,
}),
cjsSplitting(),
es5(),
sizeReporter(),
terserPlugin({
minifyOptions: options.minify,
format,
terserOptions: options.terserOptions,
}),
])
await runEsbuild(options, {
pluginContainer,
format,
css: index === 0 || options.injectStyle ? css : undefined,
logger,
buildDependencies,
}).catch((error) => {
previousBuildDependencies.forEach((v) =>
buildDependencies.add(v)
)
throw error
})
}),
])
await doOnSuccessCleanup()
: 如果有正在构建的进程,那么就把进程杀掉。如果有 onSuccess
回调存在,那么就执行。最终将onSuccessProcess
onSuccessCleanup
赋值 undefind
const prev...buildDependencies.clear()
: 这段代码目前看着是没有啥,它用于 watch
判断与 esbuild
构建分析。
if (options.clean) {...}
: 很简单了,就是用于清理旧的构建文件啦……
new PluginContainer
new PluginContainer
创建插件容器,接收 tsup
自定义插件,这些插件均为 tsup
自带插件,感兴趣的读者可以自行阅读。
const pluginContainer = new PluginContainer([
shebang(), // 设置 shell 权限,一般用于 cli 工具开发。
...(options.plugins || []), // 开发者自定义的插件
treeShakingPlugin({ // treeShaking 插件基于 rollup 开发,我记得!作者曾说过 esbuild treeShaking 不如 rollup的好……
treeshake: options.treeshake,
name: options.globalName,
silent: options.silent,
}),
cjsSplitting(),
es5(), // 构建 es5代码插件,基于swc实现
sizeReporter(), // 构建完成后在控制台打印构建包信息
terserPlugin({ // 缩小构建包体积的插件
minifyOptions: options.minify,
format,
terserOptions: options.terserOptions,
}),
])
PluginContainer
类中5个方法都比较简单,buildFinished
这个方法需要聊完 runEsbuild
方法后再去聊聊。
export class PluginContainer {
plugins: Plugin[]
context?: PluginContext
constructor(plugins: Plugin[]) {
this.plugins = plugins
}
// 设置上下文
setContext(context: PluginContext) {
this.context = context
}
// 获取上下文
getContext() {
if (!this.context) throw new Error(`Plugin context is not set`)
return this.context
}
// 修改esbuild参数 回调钩子 esbuildOptions
modifyEsbuildOptions(options: EsbuildOptions) {
for (const plugin of this.plugins) {
if (plugin.esbuildOptions) {
plugin.esbuildOptions.call(this.getContext(), options)
}
}
}
// 构建开始 回调钩子 buildStart
async buildStarted() {
for (const plugin of this.plugins) {
if (plugin.buildStart) {
await plugin.buildStart.call(this.getContext())
}
}
}
// 构建结束 回调钩子 renderChunk 与 buildEnd
async buildFinished(){....}
}
runEsbuild
runEsbuild
这个方法就是整个 tsup
构建核心功能!
export async function runEsbuild(...) {
...
pluginContainer.setContext({
format,
splitting,
options,
logger,
})
await pluginContainer.buildStarted()
const esbuildPlugins: Array<EsbuildPlugin | false | undefined> = [
format === 'cjs' && nodeProtocolPlugin(),
{
name: 'modify-options',
setup(build) {
pluginContainer.modifyEsbuildOptions(build.initialOptions)
if (options.esbuildOptions) {
options.esbuildOptions(build.initialOptions, { format })
}
},
},
// esbuild's `external` option doesn't support RegExp
// So here we use a custom plugin to implement it
format !== 'iife' &&
externalPlugin({
external,
noExternal: options.noExternal,
skipNodeModulesBundle: options.skipNodeModulesBundle,
tsconfigResolvePaths: options.tsconfigResolvePaths,
}),
options.tsconfigDecoratorMetadata && swcPlugin({ logger }),
nativeNodeModulesPlugin(),
postcssPlugin({
css,
inject: options.injectStyle,
cssLoader: loader['.css'],
}),
sveltePlugin({ css }),
...(options.esbuildPlugins || []),
]
...
}
在构建之前 先设置插件容器的上下文,执行插件容器中 buildStart
钩子方法。
初始化 esbuild
插件,根据不同的 format
格式引入不同的插件。modify-options
插件用调用 pluginContainer
的 modifyEsbuildOptions
来修改 esbuild
配置。
开发者还可以通过esbuildPlugins
配置 esbuild
插件。
中间的执行 esbuild
构建我就跳过了(因为我不会 esbuild
),直接来看最后三个 if
if (result && result.warnings && !getSilent()) {
const messages = result.warnings.filter((warning) => {
if (
warning.text.includes(
`This call to "require" will not be bundled because`
) ||
warning.text.includes(`Indirect calls to "require" will not be bundled`)
)
return false
return true
})
const formatted = await formatMessages(messages, {
kind: 'warning',
color: true,
})
formatted.forEach((message) => {
consola.warn(message)
})
}
// Manually write files
if (result && result.outputFiles) {
await pluginContainer.buildFinished({
outputFiles: result.outputFiles,
metafile: result.metafile,
})
const timeInMs = Date.now() - startTime
logger.success(format, `⚡️ Build success in ${Math.floor(timeInMs)}ms`)
}
if (result.metafile) {
for (const file of Object.keys(result.metafile.inputs)) {
buildDependencies.add(file)
}
if (options.metafile) {
const outPath = path.resolve(outDir, `metafile-${format}.json`)
await fs.promises.mkdir(path.dirname(outPath), { recursive: true })
await fs.promises.writeFile(
outPath,
JSON.stringify(result.metafile),
'utf8'
)
}
}
第一个if
块: 构建错误消息,会在控制台打印出来。
第三个if
块: buildDependencies
是否还有印象? 构建完成后把 result.metafile.inputs
信息往 buildDependencies
里头塞,buildDependencies
用于监听资源变化后是否决定重新构建。下文讲到 startWatch
时会聊到。如果 option.metafile
配置存在会生成构建 "元文件"。
第二个if
块: 调用 pluginContainer
的 buildFinished
方法,回调 renderChunk
与 buildEnd
钩子。
buildFinished
async buildFinished({
outputFiles,
metafile,
}: {
outputFiles: OutputFile[]
metafile?: Metafile
}) {
const files: Array<ChunkInfo | AssetInfo> = outputFiles
.filter((file) => !file.path.endsWith('.map'))
.map((file): ChunkInfo | AssetInfo => {
if (isJS(file.path) || isCSS(file.path)) {
const relativePath = path.relative(process.cwd(), file.path)
const meta = metafile?.outputs[relativePath]
return {
type: 'chunk',
path: file.path,
code: file.text,
map: outputFiles.find((f) => f.path === `${file.path}.map`)?.text,
entryPoint: meta?.entryPoint,
exports: meta?.exports,
imports: meta?.imports,
}
} else {
return { type: 'asset', path: file.path, contents: file.contents }
}
})
const writtenFiles: WrittenFile[] = []
await Promise.all(
files.map(async (info) => {
for (const plugin of this.plugins) {
if (info.type === 'chunk' && plugin.renderChunk) {
const result = await plugin.renderChunk.call(
this.getContext(),
info.code,
info
)
if (result) {
info.code = result.code
if (result.map) {
const originalConsumer = await new SourceMapConsumer(
parseSourceMap(info.map)
)
const newConsumer = await new SourceMapConsumer(
parseSourceMap(result.map)
)
const generator =
SourceMapGenerator.fromSourceMap(originalConsumer)
generator.applySourceMap(newConsumer, info.path)
info.map = generator.toJSON()
originalConsumer.destroy()
newConsumer.destroy()
}
}
}
}
const inlineSourceMap = this.context!.options.sourcemap === 'inline'
const contents =
info.type === 'chunk'
? info.code +
getSourcemapComment(
inlineSourceMap,
info.map,
info.path,
isCSS(info.path)
)
: info.contents
await outputFile(info.path, contents, {
mode: info.type === 'chunk' ? info.mode : undefined,
})
writtenFiles.push({
get name() {
return path.relative(process.cwd(), info.path)
},
get size() {
return contents.length
},
})
if (info.type === 'chunk' && info.map && !inlineSourceMap) {
const map =
typeof info.map === 'string' ? JSON.parse(info.map) : info.map
const outPath = `${info.path}.map`
const contents = JSON.stringify(map)
await outputFile(outPath, contents)
writtenFiles.push({
get name() {
return path.relative(process.cwd(), outPath)
},
get size() {
return contents.length
},
})
}
})
)
for (const plugin of this.plugins) {
if (plugin.buildEnd) {
await plugin.buildEnd.call(this.getContext(), { writtenFiles })
}
}
}
const files: Array<ChunkInfo | AssetInfo> = outputFiles ...
这段代码我觉得可以抽一个方法,这段代码做的事情整理 outputFiles
返回 Array<ChunkInfo | AssetInfo>
。
for (const plugin of this.plugins) {
if (info.type === 'chunk' && plugin.renderChunk) {
const result = await plugin.renderChunk.call(
this.getContext(),
info.code,
info
)
if (result) {
info.code = result.code
if (result.map) {
const originalConsumer = await new SourceMapConsumer(
parseSourceMap(info.map)
)
const newConsumer = await new SourceMapConsumer(
parseSourceMap(result.map)
)
const generator =
SourceMapGenerator.fromSourceMap(originalConsumer)
generator.applySourceMap(newConsumer, info.path)
info.map = generator.toJSON()
originalConsumer.destroy()
newConsumer.destroy()
}
}
}
}
仔细看看这段代码,如果满足条件的话。就会去调用 renderChunk
钩子,renderChunk
钩子如果有返回值将 code
赋值回去,如果有map
还需要重新生成source map
。
为什么要要重新将 code
赋值回去还要重新生成source map
呢?
这里解释起来有点麻烦,读者需要配合 tsup
插件来理解,下面两句话没看懂没关系,大概有个概念就行。最后我会讲一个 tsup
插件让大家心里有个底。
其实说起来也不难,因为 renderChunk
钩子是被允许修改代码,renderChunk
钩子拿到的是 esbuild
构建完的 chunk
代码,这个时候代码还没有写入到文件中。
开发者可以在 renderChunk
钩子对代码二次加工。比如说 terserPlugin
会对代码进行一次压缩,es5
(tsup插件)这个插件会调用 @swc/core
对 chunk
二次加工得到真正的 es5
代码!
const inlineSourceMap = this.context!.options.sourcemap === 'inline'
const contents =
info.type === 'chunk'
? info.code +
getSourcemapComment(
inlineSourceMap,
info.map,
info.path,
isCSS(info.path)
)
: info.contents
await outputFile(info.path, contents, {
mode: info.type === 'chunk' ? info.mode : undefined,
})
writtenFiles.push({
get name() {
return path.relative(process.cwd(), info.path)
},
get size() {
return contents.length
},
})
if (info.type === 'chunk' && info.map && !inlineSourceMap) {
const map =
typeof info.map === 'string' ? JSON.parse(info.map) : info.map
const outPath = `${info.path}.map`
const contents = JSON.stringify(map)
await outputFile(outPath, contents)
writtenFiles.push({
get name() {
return path.relative(process.cwd(), outPath)
},
get size() {
return contents.length
},
})
}
这段就挺好理解的了,直接将文件写入本地。
要注意这里再写文件的时候会判断source map
的方式,如果是 inline
会在代码结尾处拼接source map
的 base64
(详情见getSourcemapComment
),如果不是 inline
则会在文件夹中生成 .map
文件。
writtenFiles
则是收集写入本地的文件信息,这个对象会被传递给下个钩子。
startWatcher
const startWatcher = async () => {
if (!options.watch) return
const { watch } = await import('chokidar')
const customIgnores = options.ignoreWatch
? Array.isArray(options.ignoreWatch)
? options.ignoreWatch
: [options.ignoreWatch]
: []
const ignored = [
'**/{.git,node_modules}/**',
options.outDir,
...customIgnores,
]
const watchPaths =
typeof options.watch === 'boolean'
? '.'
: Array.isArray(options.watch)
? options.watch.filter(
(path): path is string => typeof path === 'string'
)
: options.watch
const watcher = watch(watchPaths, {
ignoreInitial: true,
ignorePermissionErrors: true,
ignored,
})
watcher.on('all', async (type, file) => {
file = slash(file)
// 用来判断是否有文件需要被赋值到输出目录的
if (
options.publicDir &&
isInPublicDir(options.publicDir, file)
) {
copyPublicDir(options.publicDir, options.outDir)
return
}
let shouldSkipChange = false
if (options.watch === true) {
if (file === 'package.json' && !buildDependencies.has(file)) {
const currentHash = await getAllDepsHash(process.cwd())
shouldSkipChange = currentHash === depsHash
depsHash = currentHash
} else if (!buildDependencies.has(file)) {
shouldSkipChange = true
}
}
if (shouldSkipChange) {
return
}
debouncedBuildAll()
})
}
在 watcher.on
之前都是在处理需要被监听的文件与需要被忽略的文件所以略过……
需要关注的是 shouldSkipChange
,这个变量决定着是否重新构建,其实你只要改动的文件不是 package.json
或文件不存在于 buildDependencies
中都会执行 debouncedBuildAll
然后触发构建……
tsup 插件
export const es5 = (): Plugin => {
let enabled = false
return {
name: 'es5-target',
esbuildOptions(options) {
if (options.target === 'es5') {
options.target = 'es2020'
enabled = true
}
},
async renderChunk(code, info) {
if (!enabled || !/\.(cjs|js)$/.test(info.path)) {
return
}
const swc: typeof import('@swc/core') = localRequire('@swc/core')
if (!swc) {
throw new PrettyError(
'@swc/core is required for es5 target. Please install it with `npm install @swc/core -D`'
)
}
const result = await swc.transform(code, {
filename: info.path,
sourceMaps: this.options.sourcemap,
minify: Boolean(this.options.minify),
jsc: {
target: 'es5',
parser: {
syntax: 'ecmascript',
},
minify: this.options.minify ? {
compress: false,
mangle: {
reserved: this.options.globalName ? [this.options.globalName] : []
},
} : undefined,
},
})
return {
code: result.code,
map: result.map,
}
},
}
}
其实我们可以看到哇,这个插件使用了 @swc/core
,为啥嘞?因为 esbuild
没办法构建 es5
格式的代码,所以这个插件会修改 esbuild
构建代码的格式为 es2020
。
当这个插件接收到 esbuild
构建后的代码时,它会对代码"二次加工"(使用 @swc/core
对代码再次构建),构建出来的代码是新的代码,所以需要赋值回去(如果还有source map
的话当然同样需要重新生成拉~)
我们再看看 treeShakingPlugin
。
export const treeShakingPlugin = ({
treeshake,
name,
silent,
}: {
treeshake?: TreeshakingStrategy,
name?: string
silent?: boolean
}): Plugin => {
return {
name: 'tree-shaking',
async renderChunk(code, info) {
if (!treeshake || !/\.(cjs|js|mjs)$/.test(info.path)) return
const bundle = await rollup({
input: [info.path],
plugins: [
hashbang(),
{
name: 'tsup',
resolveId(source) {
if (source === info.path) return source
return false
},
load(id) {
if (id === info.path) return code
},
},
],
treeshake: treeshake,
makeAbsoluteExternalsRelative: false,
preserveEntrySignatures: 'exports-only',
onwarn: silent ? () => {} : undefined,
})
const result = await bundle.generate({
interop: 'auto',
format: this.format,
file: 'out.js',
sourcemap: !!this.options.sourcemap,
name
})
for (const file of result.output) {
if (file.type === 'chunk') {
if (file.fileName.endsWith('out.js')) {
return {
code: file.code,
map: file.map,
}
}
}
}
},
}
}
有点懵对吧? 这个插件的作用是对 code
进行 treeShaking
!
是滴,对 code
进行 treeShaking
。也就是说 esbuild
构建没有做 treeShaking
,这个脏活累活交给 rollup
来干了。
完结
我个人学到不少的东西,不知道读者能够吸收多少,建议读者能够自己去读一遍。
看不懂的话私信 or 评论问我就好了,看到就会回复。
转载自:https://juejin.cn/post/7209226686373986365