Vite 是如何发布 npm 包的?

4,397 阅读6分钟

Vite 的发布又改了发布方式 😭😭😭😭😭😭,不过其实也不用太担心,改来改去,发布的核心逻辑都是不变的,大家看的时候就参考参考就行了。 这个故事告诉我们,要等代码稳定了之后再写文章 😭😭😭😭😭😭 该文章讲解的 vite 版本

前言

最近在做 monorepo 的 npm 包发布,参考(照抄)了 Vite 的发布方式。然而当我写完的第二天,Vite 重构了它的发布脚本(2022/2/11)😭😭😭 于是,我又再次的修改了我的项目,并写一篇文章来专门介绍一下。

本文将分两个部分:

  • 从用户的角度看 Vite 发布
  • Vite 发布的实现方式

从用户的角度看 Vite 发布

Vite 的发布重构之后,用户可以通过一个可视化的界面,对 Vite 进行发布。

下图是 GitHub Action 界面,通过手动触发 GitHub Action 的方式对 vite 及一些官方的 vite 插件进行发布。

值得注意的是,只有项目成员才能看到并执行 release 工作流。如果我们需要体验运行该工作流,需要先 fork vite 仓库,再到 Action 界面执行

image-20220212114914215

其中 package 和 type 是可选的,分别对应要发布的 npm 包及版本号的生成方式(如仅增加 minior 修订版本号)

运行之后,就会自动执行工作流,发布 vite 到 npm。

Vite 发布的实现方式

github 的工作流程配置文件,都存储在仓库的 .github/workflows 下。

我们运行的是 release 工作流程,因此我们需要看 .github/workflows/release.yml 的配置

release.yml

我们将 release.yml 拆成几部分来看:

  • 定义用户可以选择的参数
  • 运行 job,发布到 npm

定义用户的可选参数

可选参数有:

  • 运行发布的分支,默认为 main 分支
  • 需要发布的 npm 包,默认是 vite
  • 版本号生成方式
name: Release

on:
  workflow_dispatch:
    inputs:
      branch:
        description: "branch"
        required: true
        type: string
        default: "main"
      package:
        description: "package"
        required: true
        type: choice
        options:
          - vite
          - plugin-legacy
          - plugin-vue
          - plugin-vue-jsx
          - plugin-react
          - create-vite
      type:
        description: "type"
        required: true
        type: choice
        options:
          - next
          - stable
          - minor-beta
          - major-beta
          - minor
          - major

效果如下:

image-20220212125756260

表单项 Use workflow from,并没有在 release.yml 中定义,它是 GitHub 运行工作流程时自带的选项。

它的作用是,读取哪个分支的 release.yml ,因为不同分支的 release.yml 可能是不一样的。

如果选择的分支没有 release.yml 文件,则不会再有这三个选项,且流水线不能运行。

运行 job,发布到 npm

主要有以下几个步骤:

  • 使用 Ubuntu 镜像进行构建
  • 拉取 git 仓库代码,拉取选中分支
  • 使用 node 16
  • 安装 pnpm
  • pnpm install,如果有缓存,则利用缓存进行加速安装
  • 在需要发布的包的项目,执行 pnpm run release
jobs:
  release:
    # prevents this action from running on forks 
    # 避免 fork 的仓库运行该工作流
    if: github.repository == 'vitejs/vite'
    name: Release
    runs-on: ${{ matrix.os }}
    environment: Release
    strategy:
      matrix:
        # pseudo-matrix for convenience, NEVER use more than a single combination
        # 伪矩阵,为了方便(可能是 vite 的开发者从别处拷贝的代码做了少量改动),不要使用一个以上的组合
        # 意思是使用最新的 ubuntu 系统以及使用 node 16 运行 job
        node: [16]
        os: [ubuntu-latest]
    steps:
    
    	# 拉取 git 代码
      - name: checkout
        uses: actions/checkout@v2
        with:
		  # 拉取对应的分支
          ref: ${{ github.event.inputs.branch }}
          # fetch-depth 设置为 0,可以获取 git 的所有 commit 历史和 tag。不设置的话默认是只获取最新的一个 commit 信息。
          # 获取所有 git commit 是为了生成 changelog
          fetch-depth: 0
          
      	# 使用前面定义的 node 16 版本
      - uses: actions/setup-node@v2
        with:
          node-version: ${{ matrix.node }}
          
      # 设置 git user,后面会用该 user 提交代码
      - run: git config user.name vitebot
      - run: git config user.email vitejs.bot@gmail.com
      
      # 安装 pnpm 和 yarn,pnpm 用于安装依赖,yarn 用于发布 npm 包
      - run: npm i -g pnpm@6
      - run: npm i -g yarn # even if the repo is using pnpm, Vite still uses yarn v1 for publishing
      - run: yarn config set registry https://registry.npmjs.org # Yarn's default registry proxy doesn't work in CI
      
      # 使用 node 16,再次使用是为了指定使用缓存(之前没有安装 pnpm)
      # cache 使用方式详情见:https://github.com/actions/setup-node#caching-packages-dependencies
      - uses: actions/setup-node@v2
        with:
          node-version: ${{ matrix.node }}
          cache: "pnpm"
          cache-dependency-path: "**/pnpm-lock.yaml"
      
      # 安装依赖
      - name: install
        run: pnpm install --frozen-lockfile --prefer-offline
      
      # 创建 .npmrc,用于存储 npm 发布的秘钥,发布时能够自动读取 .npmrc 的秘钥,避免输入密码等用户交互
      - name: Creating .npmrc
        run: |
          cat << EOF > "$HOME/.npmrc"
            //registry.npmjs.org/:_authToken=$NPM_TOKEN
          EOF
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      
      # 运行【packages/需要发布的包】目录下 package.json 中的 release 脚本,并传入参数
      # --quiet 跳过命令行交互的流程,流水线无法进行交互
      # --type 传入版本号的生成方式
      - name: Release
        run: pnpm --dir packages/${{ github.event.inputs.package }} release -- --quiet --type ${{ github.event.inputs.type }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

release 脚本

每个包目录下有一个 package.json,且里面都有一个 release 脚本,例如 vite:

// 节选自 /packages/vite/package.json
{
  "name": "vite",
  "version": "2.8.1",
  "author": "Evan You",
  "scripts": {
    "release": "ts-node ../../scripts/release.ts"
  }
}

实际上是执行了 vite 仓库根目录 scripts 目录下的 release.ts。vite 仓库下的所有包,都是用该脚本进行发布

该文件共 250 行,不多,我们先看看大概结构:

async function main(): Promise<void> {
	// 暂时省略
}

main().catch((err) => {
  console.error(err)
})

整个文件就是执行 main 函数,如果有错误则输出。

main 函数主要包括以下几个步骤:

  1. 生成新的版本号 targetVersion
  2. 二次确认是否发布
  3. 更新 package.json 版本号
  4. 执行构建
  5. 生成 changelog
  6. 发布 npm 包
  7. 提交到 GitHub

生成新的版本号

如果执行脚本时,没有指定版本号,则生成新的版本号

生成的规则,是根据命令行参数 --type 生成。行数比较多,其实大部分都是一些错误处理及提示,不必细究。需要知道的是,生成版本号,使用的是 semver 这个 npm 包。

const currentVersion = '1.0.0'
const inc: (i: ReleaseType) => string = (i) =>
  semver.inc(currentVersion, i, 'beta')!

inc('major')  		// 2.0.0,如果第二个参数不是 preXXX(premajor等),则第三个参数会被忽略
inc('premajor')		// 2.0.0-beta.0
inc('minor')		// 1.1.0
inc('preminor')		// 1.1.0-beta.0
inc('patch')		// 1.0.1
inc('prepatch')		// 1.0.1-beta.0
inc('prerelease')	// 1.0.1-beta.0

如果没有传入 --type,则通过命令行交互,选择版本类型。这种情况发生在手动在项目中调用 pnpm run release,这也是发布重构前的发布方式。命令行交互方式如下:

image-20220212155359156

下面是代码:

// args 是使用命令行执行该脚本时,传入的参数
// 将第一个参数作为 targetVersion
let targetVersion: string | undefined = args._[0]

// 如果没有传入 targetVersion,则自动生成
if (!targetVersion) {
  // 从命令行中读取 type 参数, --type xxx
  const type: string | undefined = args.type
  // 根据 type 生成版本号
  if (type) {
    const currentBeta = currentVersion.includes('beta')
    if (type === 'next') {
      targetVersion = inc(currentBeta ? 'prerelease' : 'patch')
    } else if (type === 'stable') {
      // Out of beta
      if (!currentBeta) {
        throw new Error(
          `Current version: ${currentVersion} isn't a beta, stable can't be used`
        )
      }
      targetVersion = inc('patch')
    } else if (type === 'minor-beta') {
      if (currentBeta) {
        throw new Error(
          `Current version: ${currentVersion} is already a beta, minor-beta can't be used`
        )
      }
      targetVersion = inc('preminor')
    } else if (type === 'major-beta') {
      if (currentBeta) {
        throw new Error(
          `Current version: ${currentVersion} is already a beta, major-beta can't be used`
        )
      }
      targetVersion = inc('premajor')
    } else if (type === 'minor') {
      if (currentBeta) {
        throw new Error(
          `Current version: ${currentVersion} is a beta, use stable to release it first`
        )
      }
      targetVersion = inc('minor')
    } else if (type === 'major') {
      if (currentBeta) {
        throw new Error(
          `Current version: ${currentVersion} is a beta, use stable to release it first`
        )
      }
      targetVersion = inc('major')
    } else {
      throw new Error(
        `type: ${type} isn't a valid type. Use stable, minor-beta, major-beta, or next`
      )
    }
  } else {
    // no explicit version or type, offer suggestions
    const { release }: { release: string } = await prompts({
      type: 'select',
      name: 'release',
      message: 'Select release type',
      choices: versionIncrements
        .map((i) => `${i} (${inc(i)})`)
        .concat(['custom'])
        .map((i) => ({ value: i, title: i }))
    })

    if (release === 'custom') {
      const res: { version: string } = await prompts({
        type: 'text',
        name: 'version',
        message: 'Input custom version',
        initial: currentVersion
      })
      targetVersion = res.version
    } else {
      targetVersion = release.match(/\((.*)\)/)![1]
    }
  }
}

二次确认是否发布

二次确认是否发布,beta 版本需要再次确认。

如果传了 --quiet 参数,则跳过,这个用在 GitHub CI 工作流程执行时,执行脚本会主动传入 --quiet

if (!args.quiet) {
  if (targetVersion.includes('beta') && !args.tag) {
    const { tagBeta }: { tagBeta: boolean } = await prompts({
      type: 'confirm',
      name: 'tagBeta',
      message: `Publish under dist-tag "beta"?`
    })

    if (tagBeta) args.tag = 'beta'
  }

  const { yes }: { yes: boolean } = await prompts({
    type: 'confirm',
    name: 'yes',
    message: `Releasing ${tag}. Confirm?`
  })

  if (!yes) {
    return
  }
} else {
  if (targetVersion.includes('beta') && !args.tag) {
    args.tag = 'beta'
  }
}

更新 package.json 版本号

step('\nUpdating package version...')
updateVersion(targetVersion)

updateVersion 如下,就是覆盖原有的 package.json

function updateVersion(version: string): void {
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
  pkg.version = version
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}

执行构建

执行 pnpm run build

step('\nBuilding package...')
if (!skipBuild && !isDryRun) {
  await run('pnpm', ['run', 'build'])
} else {
  console.log(`(skipped)`)
}

如果传入参数 --dry,则表示当次运行是 dry run 空运行(又称为试运行),常用于脚本调试

在 release.ts 的 dry run,则不会执行构建和上传 npm 包,但会在命令行打印出将要执行的命令行语句

生成 changelog

执行 pnpm run changelog,由于篇幅有限,changelog 怎么生成,该文章不做讲解。

step('\nGenerating changelog...')
await run('pnpm', ['run', 'changelog'])

发布 npm 包

step('\nPublishing package...')
await publishPackage(targetVersion, runIfNotDry)

publishPackage 的实现如下:

async function publishPackage(
  version: string,
  runIfNotDry: RunFn | DryRunFn
): Promise<void> {
  // yarn publish 的参数
  const publicArgs = [
    'publish',
    '--no-git-tag-version',
    '--new-version',
    version,
    '--access',
    'public'
  ]
  if (args.tag) {
    publicArgs.push(`--tag`, args.tag)
  }
  try {
    // important: we still use Yarn 1 to publish since we rely on its specific
    // behavior
    // 目前仍然是用 yarn 1 进行发布,还没优化
    await runIfNotDry('yarn', publicArgs, {
      stdio: 'pipe'
    })
    console.log(colors.green(`Successfully published ${pkgName}@${version}`))
  } catch (e: any) {
    if (e.stderr.match(/previously published/)) {
      console.log(colors.red(`Skipping already published: ${pkgName}`))
    } else {
      throw e
    }
  }
}

runIfNotDry 的实现:

// 运行命令行
const run: RunFn = (bin, args, opts = {}) =>
  execa(bin, args, { stdio: 'inherit', ...opts })

type DryRunFn = (bin: string, args: string[], opts?: any) => void

// dry run 模式下,只是输出命令行语句
const dryRun: DryRunFn = (bin, args, opts: any) =>
  console.log(colors.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts)

const runIfNotDry = isDryRun ? dryRun : run

runIfNotDry 在 dry run 模式下,只是输出命令行语句,不执行,仅用于本地调试。

提交到 GitHub

const tag = pkgName === 'vite' ? `v${targetVersion}` : `${pkgName}@${targetVersion}`

const { stdout } = await run('git', ['diff'], { stdio: 'pipe' })

// 如果有 git 差异,则提交 commit,并打上标签
// 如果版本号被修改,则 package.json 就会被修改
if (stdout) {
  step('\nCommitting changes...')
  await runIfNotDry('git', ['add', '-A'])
  await runIfNotDry('git', ['commit', '-m', `release: ${tag}`])
  await runIfNotDry('git', ['tag', tag])
} else {
  console.log('No changes to commit.')
}

step('\nPushing to GitHub...')
await runIfNotDry('git', ['push', 'origin', `refs/tags/${tag}`])
await runIfNotDry('git', ['push'])

if (isDryRun) {
  console.log(`\nDry run finished - run git diff to see package changes.`)
}

tag 的生成规则:

以 targetVersion 是 1.0.1 为例,如果发布的包是 vite,则 tag 为 v1.0.1。如果发布的包是其他的,如 plugin-vue,则 tag 为 plugin-vue@1.0.1

总结

我们看源码,需要有个目的,例如这次,就是学习 vite 源码的发布方式。这样带着一个目的去看源码,你会发现源码其实并没有多难;切忌对着源码仓库从头到尾看,看不懂,而且很容易会丧失掉学习的耐心和信心。

细心的你,可能会发现,其实很多时候源码也不是十分完美的

  • 就例如,release.yml 的注释就明确写明了伪矩阵是为了方便,估计就是从其他的配置文件中复制过来,做了少部分的修改。

  • 也例如,发布 npm 包时,用的仍然是 yarn 1(注释中有标明),估计应该是 vite 仓库的包管理工具从 yarn 迁移 pnpm 后,发布方式还没有迁移。

一个开源库能够成为主流,必然有其出色的地方,但毕竟开发者的时间有限,不能做到每方面都是完美的,像这种发布流程,不影响核心代码,没有高的优先级进行优化处理,也是正常。

因此,我们更多的是要从开源代码中学到其精华,将其改进优化,运用到自己的项目中去。

果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力