likes
comments
collection
share

简单的聊聊 tsup 的源码

作者站长头像
站长
· 阅读数 57

前言

本章来简单的聊聊 tsup 的源码。

每个人的思维不同,可能我认为难的地方读者觉得简单,亦或反之。鄙人技术能力有限,讲的不好的地方望读者指点指点。

有些细节的地方我会略过,只讲整体的功能。

所以本文不是逐行源码解析,本文不是逐行源码解析,本文不是逐行源码解析。

tips: 源码版本 v6.6.3,源码版本最好与我保持一致!

cli 入口

克隆下 tsup项目安装下依赖。

打开 package.json,可以看到 "tsup": "dist/cli-default.js"

简单的聊聊 tsup 的源码

那么就说明主入口就是cli-default.jscli-default.js 中使用 cli-main.js,现阶段目标 cli-main.ts

tsup 使用 cac 注册的的 cli.options(' 是用来接受 shell 命令行传递的配置。

简单的聊聊 tsup 的源码

重点在 const { build } = await import('.'),这里告诉我们 index.ts 是构建的入口文件。

构建入口

打开 index.ts 看到密密麻麻的代码,没事,一个个去拆解。

index.ts 有4个方法,先逐个方法击破。

简单的聊聊 tsup 的源码

  1. defineConfigtsup 配置项时可以用它定义,跟 vitedefineConfig “用意”一致(其目的为了代码提示)

  2. killProcess 单看名字是杀进程的,只是暂时不清楚用在哪里。

  3. normalizeOptions 处理配置参数的,规范一下配置项,还不清楚在哪里使用。

  4. build 核心功能用来构建代码,该方法会被 cli 调用。

defineConfigkillProcess 看名字大概知道干啥的了,那么就来解析 normalizeOptionsbuild 把。

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 在哪里使用,往下继续看把……

构建部分会执行两个方法 dtsTaskmainTasks

dtsTask

看名字就知道它用来处理 .d.ts 的,先看看这张图,大概熟悉下 dtsTask执行的流程。

简单的聊聊 tsup 的源码

dtsTask 方法用 worker 开辟新的进程调用 rollup.js(对应 rollup.ts),worker.postMessage 传递的消息由 rollupparentPort接收。

parentPort 接收到消息开始执行 startRollup 方法构建 d.ts 文件。

startRollup 执行 getRollupConfig 方法获取构建需要的配置。获取到配置文件后判断是否开启了 watch,开不开启 watch 最终的结果都是一样的,都会生成构建结果 .d.ts 文件。

watchRolluprunRollup 具体的内部源码我就不赘述了,感兴趣的朋友可以自行查看,我大概讲讲两个方法的不同处。

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 方法则调用 rolluprollup方法(没错就是叫这个名字!),构建完成后会去调用 reportSize 在控制台打印构建相关信息。

至于为什么要使用 Worker 呢? 因为 ts 类型处理真的慢……

简单的聊聊 tsup 的源码

源码构建才花了 21 毫秒,.d.ts 花了 497 毫秒。开辟 worker 用来处理 .d.ts 不要影响到源码的构建。

dtsTask 小结

  1. dtsTask 开辟一个 worker,调用 rollup 构建 d.ts 类型。

  2. getRollupConfig 根据外部传递进来的配置项整理出 rollup所需要的配置。

  3. startRollup 方法内部判断了 watch 是否传递,如果传递则用 rollup 不同的 api 来构建 d.ts,最后通过 parentPort 发送通知 or 关闭。

mainTasks

mainTasks 负责源码的构建了。

简单的聊聊 tsup 的源码

// 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 这个方法,看下面这张图大概清楚做了什么事情。

简单的聊聊 tsup 的源码

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 插件用调用 pluginContainermodifyEsbuildOptions 来修改 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块: 调用 pluginContainerbuildFinished 方法,回调 renderChunkbuildEnd 钩子。

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/corechunk二次加工得到真正的 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 mapbase64(详情见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
评论
请登录