vlambda博客
学习文章列表

学习下 vue.js 是如何发布版本的?

前言

尤大宣布了 vue 3 版本在 2022 年 2 月 7 日成为默认安装的版本(也就是 tagnext 改为了 latest,以后我们可以直接使用 npm i vue 来使用 vue3 了),本文我们通过阅读 vuejs/core/scripts/release.js 文件,来学习下尤大是怎么发布 vuejs 的吧!

环境准备

  • vscode
  • vue 版本 v3.2.20

克隆代码

vue 源码:git clone [email protected]:vuejs/core.git

切换 tag 到 v3.2.20

安装依赖

这里注意一下!!!安装依赖时如果不是使用 yarn,会输出警告并结束进程;

原因:package.json 文件的 npm scripts 字段中有条 preinstsall 命令 ,该条命令会在我们执行 npm install 时,先执行 preinstall 命令

// package.json
{
  "scripts": {
    "preinstall""node ./scripts/checkYarn.js",
  }
}

checkYarn.js 内部通过环境变量 process.env.execpath 正则匹配判断是否有 yarn.js,没有则输出警告退出进程。

学习下 vue.js 是如何发布版本的?
image-20220327104554367

为什么会先执行 preinstall 命令呢,这是 npm scripts 约定的一套命名匹配规则,当我们命令以 prepost 开头都会被匹配执行,pre 开头可以看成是命令的前置钩子,post 开头为后置钩子。例如我定义了一条命令 myscript,那么 premyscriptpostmyscript 也会被执行,执行顺序:premyscript => myscript => postmyscript

{
  "scripts": {
    "premyscript""在 myscript 前执行",
    "myscript""xxx",
    "postmyscript""在 myscript 后执行"
  }
}

此外,scripts 还有其他内置的生命周期钩子,具体见 scripts 字段说明 npm-scripts[1]

运行调试

点击调式脚本 release 脚本命令,调试 release.js 源码。

学习下 vue.js 是如何发布版本的?
image-20220327105250295

release.js 源码

我们看到在最后面执行了 main 函数,该函数就是入口,直接从这开始一步一步看。

学习下 vue.js 是如何发布版本的?

总体流程

main 内部执行流程主要做了以下事情:

  1. 确定和校验要发布的版本号

  2. 判断是否需要执行测试用例

  3. 更新所有 vue 相关的版本号

  4. 打包编译所有包及测试类型声明文件

  5. 生成 changelog

  6. 提交代码 git add、git commit

  7. 发布新版本包到 npm

  8. 代码 push 到 github 仓库

接下来我们分步看具体代码,每一步从 main 函数开始看

1. 确定和校验要发布的版本号

确定版本号,这里分为两种情况:

  1. 执行时指定了版本号,则 targetVersion 变量为该值,例如:yarn run release 1.3.20, 则 targetVersion 值为 1.3.20

  2. 未指定版本号,则提供选项供选择(选项的版本号是在 currentVersion 的基础上递增);执行时若指定 preid 参数,例如yarn run release --perid=alpha.1, 则会多出一些先行版本号选项,如下图:

    image-20220327150827112

校验版本号,通过调用 semver.valid() 方法来校验版本号是否符合规范。

相关代码

// args 包含命令行传入的参数
const args = require('minimist')(process.argv.slice(2))
// 版本号规范工具
const semver = require('semver')
// 获取当前版本号,通过读取 package.json version字段
const currentVersion = require('../package.json').version
// 用来创建命令行提示符
const { prompt } = require('enquirer')
// 获取先行版本号,通过读取命令行参数指定或 package.json version 字段
// 例如执行时指定了 preid 字段,yarn run release --preid=beta.0, 则 preId 为 beta.0
const preId =
  args.preid ||
  (semver.prerelease(currentVersion) && semver.prerelease(currentVersion)[0])
// 版本号类型选项,作为未指定版本号时提供的选项
const versionIncrements = [
  'patch',
  'minor',
  'major',
  ...(preId ? ['prepatch''preminor''premajor''prerelease'] : [])
]
// 递增版本号
const inc = i => semver.inc(currentVersion, i, preId)

function main({
    // 获取命令行版本号参数值,例如执行时指定了版本号参数,node release.js 1.2.3, 则 args 为 [_: [1.2.3]],
    // targetVersion 为 1.2.3
    let targetVersion = args._[0]
 // 如果没有通过命令行参数指定版本,则提供选项选择
    if (!targetVersion) {
        // 提供版本选择
        const { release } = await prompt({
            type'select',
            name'release',
            message'Select release type',
            // 版本号选项
            choices: versionIncrements.map(i => `${i} (${inc(i)})`).concat(['custom'])
        })
        // 如果选的是 custom,则自定义输入
        if (release === 'custom') {
            targetVersion = (
                await prompt({
                    type'input',
                    name'version',
                    message'Input custom version',
                    initial: currentVersion
                })
            ).version
        } else {
            targetVersion = release.match(/\((.*)\)/)[1]
        }
    }
 // 校验版本号是否符合规范,不符合则抛出错误
    if (!semver.valid(targetVersion)) {
        throw new Error(`invalid target version: ${targetVersion}`)
    }
 // 进一步确定是否是要发布的版本
    const { yes } = await prompt({
        type'confirm',
        name'yes',
        message`Releasing v${targetVersion}. Confirm?`
    })
    // 不是,则不再往后执行
    if (!yes) {
        return
    }
    // ...
}

引人的相关包:

  • minimist [2] 解析命令行参数;
  • semver [3] 版本号规范工具;
  • enquirer [4] 创建友好、简单直观的命令行提示符。

2. 判断是否需要执行测试用例

相关代码

const args = require('minimist')(process.argv.slice(2))
// 命令执行工具
const execa = require('execa')
// 是否执行实际代码,通过命令行参数 --dry 指定,例如 yarn run release --dry
// 为 true 则不执行实际代码,只打印输出一些相关信息
const isDryRun = args.dry
// 是否跳过执行测试,可通过命令行参数 --skipTests 指定
const skipTests = args.skipTests
const run = (bin, args, opts = {}) =>
  execa(bin, args, { stdio'inherit', ...opts })

function main({
    step('\nRunning tests...')
    // 命令行参数没有指定 --skipTests 和 --isDryRun 时,执行测试
    if (!skipTests && !isDryRun) {
        await run(bin('jest'), ['--clearCache'])
        await run('yarn', ['test''--bail'])
    } else {
        console.log(`(skipped)`)
    }
}

引人的相关包:

  • execa [5] 命令执行工具,使用 node 子进程执行。

3. 更新所有 vue 相关的版本号

例如当前版本为 3.2.4,通过上面第一步确定版本号为 3.2.5,则更新以下 vue 相关包的版本到 3.2.5

image-20220327233422038

相关代码

// 给终端字符串设置样式
const chalk = require('chalk')
// 获取根目录 packages 下子目录的绝对路径
const getPkgRoot = pkg => path.resolve(__dirname, '../packages/' + pkg)
// 根目录 packages 下的所有子文件夹名
const packages = fs
  .readdirSync(path.resolve(__dirname, '../packages'))
  .filter(p => !p.endsWith('.ts') && !p.startsWith('.'))

function main({
    // targetVersion 为第一个步骤获取到的值
    step('\nUpdating cross dependencies...')
    updateVersions(targetVersion)
}

function updateVersions(version{
  // 1. 修改根文件夹下 package.json version 字段版本号
  updatePackage(path.resolve(__dirname, '..'), version)
  // 2. 修改 packages 文件夹下所有包及 vue 相关依赖的版本号
  packages.forEach(p => updatePackage(getPkgRoot(p), version))
}

function updatePackage(pkgRoot, version{
  // package.json 路径
  const pkgPath = path.resolve(pkgRoot, 'package.json')
  // 读取 package.json
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  // 修改 package.json version 字段
  pkg.version = version
  // 更新 dependencies 字段下依赖包版本
  updateDeps(pkg, 'dependencies', version)
  // 更新 peerDependencies 字段下依赖包版本
  updateDeps(pkg, 'peerDependencies', version)
  // 更新 package.json 文件内容
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null2) + '\n')
}
// 更新 vue 相关的依赖包版本(以 @vue 开头且是 packages 文件夹下的包)
function updateDeps(pkg, depType, version{
  const deps = pkg[depType]
  if (!deps) return
  Object.keys(deps).forEach(dep => {
    // 修改版本号,条件:依赖包的名称如果等于 'vue' 或者 以 @vue 开头且为 packages 文件夹下面的包
    if (
      dep === 'vue' ||
      (dep.startsWith('@vue') && packages.includes(dep.replace(/^@vue\//'')))
    ) {
      console.log(
        chalk.yellow(`${pkg.name} -> ${depType} -> ${dep}@${version}`)
      )
      deps[dep] = version
    }
  })
}

4. 打包编译所有包及测试类型声明文件

相关代码

const args = require('minimist')(process.argv.slice(2))
// 命令行命令执行工具,启动子进程执行
const execa = require('execa')
const isDryRun = args.dry
// 是否跳过执行测试,可通过命令行参数 --skipTests 指定
const skipTests = args.skipTests
// 是否跳过执行打包,可通过命令行参数 --skipBuild 指定
const skipBuild = args.skipBuild
//
const run = (bin, args, opts = {}) =>
  execa(bin, args, { stdio'inherit', ...opts })

function main({
    step('\nBuilding all packages...')
    // 命令行参数没有指定 --skipBuild 和 --skipTests 时,执行 yarn build --release 和 yarn test-dts-only
    if (!skipBuild && !isDryRun) {
        await run('yarn', ['build''--release'])
        // test generated dts files
        step('\nVerifying type declarations...')
        await run('yarn', ['test-dts-only'])
    } else {
        console.log(`(skipped)`)
    }
}

5. 生成 changelog

相关代码

const execa = require('execa')
const run = (bin, args, opts = {}) =>
  execa(bin, args, { stdio'inherit', ...opts })

async function main({
    // 执行命令 yarn changelog
 await run(`yarn`, ['changelog'])
}

changelog 命令,使用 conventional-changelog-cli 包自动生成 git 提交记录的日志文件 CHANGELOG.md

// 根目录下的 package.json
{
    "scripts": {
        "changelog""conventional-changelog -p angular -i CHANGELOG.md -s",
    },
}

6. 提交代码 git add、git commit

相关代码

const args = require('minimist')(process.argv.slice(2))
const execa = require('execa')
// 可通过 --dry 指定
const isDryRun = args.dry
const run = (bin, args, opts = {}) =>
  execa(bin, args, { stdio'inherit', ...opts })
// 空跑,只打印信息不执行实际代码
const dryRun = (bin, args, opts = {}) =>
  console.log(chalk.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts)
const runIfNotDry = isDryRun ? dryRun : run

function main({
    // 通过 git diff 是否有文件改动,若有改动,stdout 为 git diff 输出的改动内容
    const { stdout } = await run('git', ['diff'], { stdio'pipe' })
    if (stdout) {
        step('\nCommitting changes...')
        // git add -A
        await runIfNotDry('git', ['add''-A'])
        // git commit -m "release: vxxx"
        await runIfNotDry('git', ['commit''-m'`release: v${targetVersion}`])
    } else {
        console.log('No changes to commit.')
    }
}

7. 发布新版本包到 npm

这段代码主要的意思是,packages 文件夹下的所有包,除了 vue 包 tag 需要标记为 next 外,其他包都可以将版本号 tag 标记为 latest

PS: 因为现在 vue 3 还没有成为安装的默认版本(也就是 tag 为 latest),所以我们现在在安装 vue 3 包时,需要带上 tag,即 npm i vue@next,当后续 vue 3 成为默认版本后,我们就可以直接用 npm i vue

相关代码

const path = require('path')
// packages 根目录下的所有子文件夹名
const packages = fs
  .readdirSync(path.resolve(__dirname, '../packages'))
  .filter(p => !p.endsWith('.ts') && !p.startsWith('.'))
// 获取根目录 packages 下子目录的绝对路径
const getPkgRoot = pkg => path.resolve(__dirname, '../packages/' + pkg)

async function main({
    step('\nPublishing packages...')
    for (const pkg of packages) {
        await publishPackage(pkg, targetVersion, runIfNotDry)
    }
}

async function publishPackage(pkgName, version, runIfNotDry{
    if (skippedPackages.includes(pkgName)) {
        return
    }
    // 文件夹绝对路径
    const pkgRoot = getPkgRoot(pkgName)
    // package.json 文件的绝对路径
    const pkgPath = path.resolve(pkgRoot, 'package.json')
    // 读取 packaeg.json 文件内容
    const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
    // 如果 private 字段声明为 true 也就是定义为私有包,则不发布
    if (pkg.private) {
        return
    }
    // For now, all 3.x packages except "vue" can be published as
    // `latest`, whereas "vue" will be published under the "next" tag.
    let releaseTag = null
    if (args.tag) {
        releaseTag = args.tag
    } else if (version.includes('alpha')) {
        releaseTag = 'alpha'
    } else if (version.includes('beta')) {
        releaseTag = 'beta'
    } else if (version.includes('rc')) {
        releaseTag = 'rc'
    } else if (pkgName === 'vue') {
        // TODO remove when 3.x becomes default
        releaseTag = 'next'
    }

    // TODO use inferred release channel after official 3.0 release
    // const releaseTag = semver.prerelease(version)[0] || null

    step(`Publishing ${pkgName}...`)
    try {
        await runIfNotDry(
            'yarn',
            [
                'publish',
                '--new-version',
                version,
                ...(releaseTag ? ['--tag', releaseTag] : []),
                '--access',
                'public'
            ],
            {
                cwd: pkgRoot,
                stdio'pipe'
            }
        )
        console.log(chalk.green(`Successfully published ${pkgName}@${version}`))
    } catch (e) {
        if (e.stderr.match(/previously published/)) {
            console.log(chalk.red(`Skipping already published: ${pkgName}`))
        } else {
            throw e
        }
    }
}

8. 代码 push 到 github 仓库

相关代码

function main({
    // push to GitHub
    step('\nPushing to GitHub...')
    // 创建版本标签
    await runIfNotDry('git', ['tag'`v${targetVersion}`])
    // 将标签推送到 github 仓库
    await runIfNotDry('git', ['push''origin'`refs/tags/v${targetVersion}`])
    await runIfNotDry('git', ['push'])

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

    if (skippedPackages.length) {
        console.log(
            chalk.yellow(
                `The following packages are skipped and NOT published:\n- ${skippedPackages.join(
                    '\n- '
                )}
`

            )
        )
    }
}

以上,就是所有的流程了。

对比最新版本内容

由于我当时看的版本是 v3.2.20,在写文章时最新已经是 v3.2.31 了,所以加了这个章节,但是 release.js 文件最新的内容基本的流程没有改变,只是改动了一些小细节。

以下为对比 vue 版本 v3.2.20 <=> v3.2.31 release.js 的变更内容

1. 包管理器变更

包管理器由之前的 yarn 改为了 pnpm(具体查看 PR - workflow: move to pnpm #4766[6]),跟之前对比有如下更改:

  1. package.json preinstall 钩子对应的执行文件,由之前 checkYarn.js 改为了 preinstall.js,即由校验是否是 yarn 改为了校验是否是 pnpm

    // package.json
    {
      "scripts": {
        // 现在
        "preinstall""node ./scripts/preinstall.js",
        // 以前
        "preinstall""node ./scripts/checkYarn.js"
      }
    }
    // 现在
    if (!/pnpm/.test(process.env.npm_execpath || '')) {
      // ...
    }
    // 以前
    if (!/yarn\.js$/.test(process.env.npm_execpath || '')) {
      // ...
    }
  2. release.js 文件里面由 yarn 运行的命令改为由 pnpm 运行,例如

    async function main({
        // generate changelog
        // 现在
        await run(`pnpm`, ['run''changelog'])
        // 之前
        await run(`yarn`, ['changelog'])
    }
  3. 在之前 流程 5 生成 changelog 之后多加了一步 更新 pnpm-lock.yaml 文件

    async function main({
        // update pnpm-lock.yaml
        step('\nUpdating lockfile...')
        // 安装依赖,--prefer-offline 选项意思是安装依赖时优先使用本地缓存,若本地缺失数据再去远程拉取
        await run(`pnpm`, ['install''--prefer-offline'])
    }

2. 删除 vue 包 next tag

由于 vue 3 版本已经改为了默认的安装版本,所以这里将之前流程 7. 发布新版本包到 npm 里面给 vue 包添加 next tag 的判断去除了

function publishPckage(pkgName, version, runIfNotDry{
  // 现在
    let releaseTag = null
    if (args.tag) {
        releaseTag = args.tag
    } else if (version.includes('alpha')) {
        releaseTag = 'alpha'
    } else if (version.includes('beta')) {
        releaseTag = 'beta'
    } else if (version.includes('rc')) {
        releaseTag = 'rc'
    }
    // 已删除
    // else if (pkgName === 'vue') {
        // TODO remove when 3.x becomes default
    //   releaseTag = 'next'
    // }
}

总结

通过我们逐行阅读源码,了解 vue 3 发布版本包的总体流程,学以致用,以后我们再开发一些包或工具的时候可以借鉴借鉴!!!

参考资料
[1]

npm-scripts: https://docs.npmjs.com/cli/v8/using-npm/scripts

[2]

minimist: https://www.npmjs.com/package/minimist

[3]

semver: https://www.npmjs.com/package/semver

[4]

enquirer: https://www.npmjs.com/package/enquirer

[5]

execa: https://www.npmjs.com/package/execa

[6]

PR - workflow: move to pnpm #4766: https://github.com/vuejs/core/pull/4766

[7]

Vue 3.2 发布了,那尤雨溪是怎么发布 Vue.js 的?: https://juejin.cn/post/6997943192851054606

[8]

node.js - process.argv: http://nodejs.cn/api/process.html#processargv

[9]

node.js - child Processes options.stdio: https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio

[10]

conventional-changelog-cli: https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-cli