必知必会tsup打包库原理分析

4,305 阅读8分钟

1. 前言

今天要写的是打包工具Tsup ,它可以快速打包 typescript 库,无需任何配置,并且基于esbuild进行打包,同时也可以快速生成ts类型,它还支持Cli脚手架运行,方便又高效

随着esbuild的兴起,越来越多的打包工具开始使用esbuild做为打包底层工具,其中Vite最具代表性,它就是采用esbuild来支持 .tsjsx.tsx 代码的转化,当然 Vite 目前主要用于项目打包中,而 Tsup则主要用于typescript库的打包,它支持watch模式,开发过程中更改代码可以快速看到效果,极大地提高了开发效率。

相信很多小伙伴已经惊奇这个能力是怎么做到的,那么我通过Tsup这个库来分析下它是如何进行打包的,同时你也可以学习到esbuildplugin的使用、Rollupplugin的使用、如何封装库里面的插件系统即pluginContainer以及node相关的一些库的功能

2. 使用

  1. 安装

    这里就使用pnpm了,pnpm的安装速度比较快以及在依赖管理方面进行了优化,具体可见文章Pnpm: 最先进的包管理工具

    pnpm add tsup -D
    
  2. 配置文件

    配置比较简单,看一下官方文档基本上就可以直接上手使用

    目前支持了如下几种配置文件类型

    • 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,
    })
    
  3. 直接通过 script 脚本运行

    为什么推荐你用脚本呢?因为可以重复使用只需要执行一条npm命令就行了

    当然你也可以直接使用 cli 命令,因为tsup也支持了 cli 命令,cli 有一个快速生成工具cac,如果你有兴趣也可以看看这个工具,本次我们不讲解这个工具。

    "script": {
         "build": "tsup",
         "dev": "tsup --watch"
     }
    

在 dev 的情况下你可以进行打包并监听文件的改变进行打包,这样就可以快速看到效果了

3. 原理分析

  1. 解析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的话都是有帮助的,当然如果你想了解插件系统怎么实现,也可以参考这个,希望对你有所帮助。