1. 前言
今天要写的是打包工具Tsup ,它可以快速打包 typescript 库,无需任何配置,并且基于esbuild进行打包,同时也可以快速生成ts类型,它还支持Cli脚手架运行,方便又高效
随着esbuild的兴起,越来越多的打包工具开始使用esbuild做为打包底层工具,其中Vite最具代表性,它就是采用esbuild来支持 .ts、jsx、.tsx 代码的转化,当然 Vite 目前主要用于项目打包中,而 Tsup则主要用于typescript库的打包,它支持watch模式,开发过程中更改代码可以快速看到效果,极大地提高了开发效率。
相信很多小伙伴已经惊奇这个能力是怎么做到的,那么我通过Tsup这个库来分析下它是如何进行打包的,同时你也可以学习到esbuild的plugin的使用、Rollup的plugin的使用、如何封装库里面的插件系统即pluginContainer以及node相关的一些库的功能
2. 使用
-
安装
这里就使用
pnpm了,pnpm的安装速度比较快以及在依赖管理方面进行了优化,具体可见文章Pnpm: 最先进的包管理工具。pnpm add tsup -D -
配置文件
配置比较简单,看一下官方文档基本上就可以直接上手使用
目前支持了如下几种配置文件类型
- tsup.config.ts
- tsup.config.js
- tsup.config.cjs
- tsup.config.json
- 以及 package.json 中配置
import { defineConfig } from 'tsup' export default defineConfig({ entry: ['src/index.ts'], splitting: false, sourcemap: true, clean: true, }) -
直接通过 script 脚本运行
为什么推荐你用脚本呢?因为可以重复使用只需要执行一条
npm命令就行了当然你也可以直接使用 cli 命令,因为
tsup也支持了 cli 命令,cli 有一个快速生成工具cac,如果你有兴趣也可以看看这个工具,本次我们不讲解这个工具。"script": { "build": "tsup", "dev": "tsup --watch" }
在 dev 的情况下你可以进行打包并监听文件的改变进行打包,这样就可以快速看到效果了
3. 原理分析
- 解析options
在
cli-main里面使用cac注册了 cli,那么我们可以看到,重点看到build方法,这个就是打包的入口文件
// index.ts下的build方法
const config =
_options.config === false
? {}
: await loadTsupConfig(
process.cwd(),
_options.config === true ? undefined : _options.config
)
在build函数里面我们可以看到loadTsupConfig这个方法,这里会解析用户的tsup的配置文件
当然会判断用户的options。config如果有就直接使用用户的如果没有就loadTsupConfig
这里可以看到对于用户的配置性就是我们需要考虑的事情了,很多时候并不仅仅是给用户开放了配置文件就行,还需要考虑到用户是否需要自定义配置文件
// load.ts
if (configPath) {
if (configPath.endsWith('.json')) {
// load package.json文件
let data = await loadJson(configPath)
if (configPath.endsWith('package.json')) {
data = data.tsup
}
if (data) {
return { path: configPath, data }
}
return {}
}
const config = await bundleRequire({
filepath: configPath,
})
return {
path: configPath,
data: config.mod.tsup || config.mod.default || config.mod,
}
}
configPath就是从那些配置文件进行获取,这里的优先级也就是上面说的那些优先级,如果同时设置两个,那么会按照优先级拿到对应的configPath,然后解析拿到对应的配置文件
const configData =
typeof config.data === 'function'
? await config.data(_options)
: config.data
这里配置文件可能是个function,支持用户直接调用,传入的则是cli的options,这样就可以让用户拿到对应的options进行处理
这里是不是想到了Vite的配置文件,Vite的配置文件也是支持function的,这样就可以让用户拿到对应的options进行处理,扩展性更高
2.创建logger
const logger = createLogger(item?.name)
logger在与用户交互行较强的工具上都是必须的,因为为了给用户更强的交互性,方便让用户知道目前进行到了哪一步、哪一步出错了、哪一步成功了、成功的用了多少时间、尺寸多大等等信息,更加方便用户的使用
库里面使用了colorette这个模块进行log输出的,大家有兴趣也可以看看这个模块或者找找有没有更好的 3.打包dts
const worker = new Worker(path.join(__dirname, './rollup.js'))
worker.postMessage({
configName: item?.name,
options: {
...options, // functions cannot be cloned
banner: undefined,
footer: undefined,
esbuildPlugins: undefined,
esbuildOptions: undefined,
plugins: undefined,
treeshake: undefined,
onSuccess: undefined,
outExtension: undefined,
}
})
dts也就是生成typescript的文件类型,用户在使用模块时能够快速的获取到类型提升开发效率
这里使用了rollup进行打包,rollup-plugin-dts这个插件进行打包,这里使用了worker进行dts的打包,这样就不会阻塞主线程,提升打包速度
inputConfig: {
input: dtsOptions.entry,
onwarn(warning, handler) {
if (
warning.code === 'UNRESOLVED_IMPORT' ||
warning.code === 'CIRCULAR_DEPENDENCY' ||
warning.code === 'EMPTY_BUNDLE'
) {
return
}
return handler(warning)
},
plugins: [
// 清除插件
tsupCleanPlugin,
// 对.ts .d.ts进行解析,其他文件进行过滤
tsResolveOptions && tsResolvePlugin(tsResolveOptions),
hashbangPlugin(),
jsonPlugin(),
// 哪些文件需要忽略比如.json .jsx等
ignoreFiles,
// dtsPlugin
dtsPlugin.default({
compilerOptions: {
...compilerOptions,
baseUrl: compilerOptions.baseUrl || '.',
// Ensure ".d.ts" modules are generated
declaration: true,
// Skip ".js" generation
noEmit: false,
emitDeclarationOnly: true,
// Skip code generation when error occurs
noEmitOnError: true,
// Avoid extra work
checkJs: false,
declarationMap: false,
skipLibCheck: true,
preserveSymlinks: false,
// Ensure we can parse the latest code
target: ts.ScriptTarget.ESNext,
},
}),
].filter(Boolean),
external: [...deps, ...(options.external || [])],
},
outputConfig: {
dir: options.outDir || 'dist',
format: 'esm',
exports: 'named',
banner: dtsOptions.banner,
footer: dtsOptions.footer,
}
调用rollup打包之前会对用户的tsconfig.json进行解析和用户的配合进行合并,然后交由dtsPlugin去进行类型打包输出
同时rollup也可以进行watch,根据文件的改变实时进行类型打包,并把结果通过postMessage发送给主线程
const startRollup = async (options: NormalizedOptions) => {
const config = await getRollupConfig(options)
if (options.watch) {
watchRollup(config)
} else {
try {
await runRollup(config)
parentPort?.postMessage('success')
} catch (error) {
parentPort?.postMessage('error')
}
parentPort?.close()
}
}
4.打包ts文件 ts文件主要是通过esbuild进行打包的,比较依赖于esbuild的功能,当然也继承了esbuild的快
...options.format.map(async (format, index) => {
const pluginContainer = new PluginContainer([
shebang(),
...(options.plugins || []),
treeShakingPlugin({
treeshake: options.treeshake,
name: options.globalName,
}),
cjsSplitting(),
es5(),
sizeReporter(),
])
await pluginContainer.buildStarted()
await runEsbuild(options, {
pluginContainer,
format,
css: index === 0 || options.injectStyle ? css : undefined,
logger,
buildDependencies,
}).catch((error) => {
previousBuildDependencies.forEach((v) =>
buildDependencies.add(v)
)
throw error
})
})
runEsbuild函数主要就是负责进行esbuild打包输出,讲pluginContainer传入,可以使用插件对各个生命周期函数进行处理,比如打包前调用pluginContainer.buildStarted进行输出或者打印信息
4.1. pluginContainer
export class PluginContainer {
plugins: Plugin[]
context?: PluginContext
constructor(plugins: Plugin[]) {
this.plugins = plugins
}
setContext(context: PluginContext) {
this.context = context
}
// ....
async buildStarted() {
for (const plugin of this.plugins) {
if (plugin.buildStart) {
await plugin.buildStart.call(this.getContext())
}
}
}
}
pluginContainer作用可以是给用户暴露各个插件的生命周期,用户可以开发插件,实现插件的高度扩展性
比如sizeReporter插件,就是在打包结束后,输出打包的文件大小
export const sizeReporter = (): Plugin => {
return {
name: 'size-reporter',
buildEnd({ writtenFiles }) {
reportSize(
this.logger,
this.format,
writtenFiles.reduce((res, file) => {
return {
...res,
[file.name]: file.size,
}
}, {})
)
},
}
}
4.2 runEsbuild方法 runEsbuild的功能主要就是对文件进行打包,输出,同时在打包输出后也会调用pluginContainer.buildFinished函数,可以在打包执行后做一些操作
const pkg = await loadPkg(process.cwd())
const deps = await getDeps(process.cwd())
const external = [
// Exclude dependencies, e.g. `lodash`, `lodash/get`
...deps.map((dep) => new RegExp(`^${dep}($|\\/|\\\\)`)),
...(options.external || []),
]
const outDir = options.outDir
const outExtension = getOutputExtensionMap(options, format, pkg.type)
const env: { [k: string]: string } = {
...options.env,
}
if (options.replaceNodeEnv) {
env.NODE_ENV =
options.minify || options.minifyWhitespace ? 'production' : 'development'
}
首先先对options数据进行处理,比如获取依赖,获取输出目录,获取文件后缀,获取环境变量等。
因为是基于esbuild打包,所以配置都是围绕着esbuild的配置进行兼容处理的。
const esbuildPlugins: Array<EsbuildPlugin | false | undefined> = [
format === 'cjs' && nodeProtocolPlugin(),
{
name: 'modify-options',
setup(build) {
pluginContainer.modifyEsbuildOptions(build.initialOptions)
if (options.esbuildOptions) {
// 用户可以传入esbuildOptions做一些callback操作
options.esbuildOptions(build.initialOptions, { format })
}
},
},
// esbuild's `external` option doesn't support RegExp
// So here we use a custom plugin to implement it
// external插件,可以将依赖排除在外
format !== 'iife' &&
externalPlugin({
external,
noExternal: options.noExternal,
skipNodeModulesBundle: options.skipNodeModulesBundle,
tsconfigResolvePaths: options.tsconfigResolvePaths,
}),
// 处理tsconfigDecoratorMetadata,使用swc触发 decorator metadata
options.tsconfigDecoratorMetadata && swcPlugin({ logger }),
// 原生模块的处理
nativeNodeModulesPlugin(),
// css打包的插件
postcssPlugin({ css, inject: options.injectStyle }),
// svelte的处理插件
sveltePlugin({ css }),
// 用户自定义的插件,最后执行
...(options.esbuildPlugins || []),
]
插件就不讲太多了,只简单讲了下其作用,因为主要是带大家了解内部的打包原理
然后就是调用esbuild的build方法进行打包输出
result = await esbuild({
entryPoints: options.entry,
format:
(format === 'cjs' && splitting) || options.treeshake ? 'esm' : format,
bundle: typeof options.bundle === 'undefined' ? true : options.bundle,
platform,
globalName: options.globalName,
jsxFactory: options.jsxFactory,
jsxFragment: options.jsxFragment,
sourcemap: options.sourcemap ? 'external' : false,
target: options.target,
banner,
footer,
tsconfig: options.tsconfig,
// ....
plugins: esbuildPlugins,
// ....
logLevel: 'error',
minify: options.minify,
})
打包完成之后,在调用pluginContainer.buildFinished方法,进行buildFinished生命周期的回调
// 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`)
}
如果设置了metafile,那么也会把对应的metafile写入到outdir目录下面的metafile-${format}.json文件中
之前讲到了dts会有watch模式监听文件变化进行打包
这里的esbuild也会监听文件变化,不同的是,esbuild打包是通过chokidar进行文件的监听的。
const { watch } = await import('chokidar')
// 获取到监听的文件路径
const watchPaths =
typeof options.watch === 'boolean'
? '.'
: Array.isArray(options.watch)
? options.watch.filter(
(path): path is string => typeof path === 'string'
)
: options.watch
// 根据watchPaths以及ignored生成watcher
const watcher = watch(watchPaths, {
ignoreInitial: true,
ignorePermissionErrors: true,
ignored,
})
// 监听文件变化,进行打包
// 其中watch的回调打包进行了debounce优化
watcher.on('all', (type, file) => {
file = slash(file)
// By default we only rebuild when imported files change
// If you specify custom `watch`, a string or multiple strings
// We rebuild when those files change
if (options.watch === true && !buildDependencies.has(file)) {
return
}
logger.info('CLI', `Change detected: ${type} ${file}`)
debouncedBuildAll()
})
最后将两个任务都放到Promise.all中执行,进行并行打包
await Promise.all([dtsTask(), mainTasks()])
总结
好了,到这里就讲完了esbuild的打包原理,虽然是相对比较浅的进行了讲解,但是如果你正在使用tsup或者你在学习esbuild的话都是有帮助的,当然如果你想了解插件系统怎么实现,也可以参考这个,希望对你有所帮助。