【源码学习】探索vue 3.2 是怎么发布的 vue-release之旅【3】

169 阅读9分钟

简介

本文档记录了我参加 vue 3.2 是怎么发布的 vue-release 源码共读的过程中的学习和思考。

参与目的:

  • 探索源码,理解工作原理
  • 学习和成长
  • 提升技术水平

作为一名前端开发者,参与这次源码共读活动。这是一个很好的机会,与志同道合的开发者一起深入探索源码、理解工作原理,并在过程中学习和成长。

本文参与学习内容:

  • 熟悉 vuejs 发布流程
  • 学会调试 nodejs 代码
  • 动手优化公司项目发布流程

源码分析

1、源码准备

打开 vue-next, 开源项目一般都能在 README.md 或者 contributing.md 找到贡献指南先看说明,按照说明书进行配置准备运行环境

本文是直接使用的若川老师的源码。可根据自己想法下载对应源码

Node.js 版本要求是 10+
Yarn 的版本要求是 1.x Yarn 1.x 可自行下载

node -v 
# v14.16.0 
# 全局安装 yarn

#若川老师源码【建议】
git clone https://github.com/lxchuan12/vue-next-analysis.git 
# macos clone可能需要添加 sudo
cd vue-next-analysis/vue-next 

# 或者克隆官方项目源码
git clone https://github.com/vuejs/vue-next.git 
cd vue-next 

# 安装 yarn 
npm install --global yarn 
# 安装依赖 
yarn # install the dependencies of the project 
# yarn release

1.1、严格校验使用 yarn 安装依赖

先查看vue-next/package.json文件

image.png

preinstall设置一个前置文件,用于限制安装依赖,此处是限制的只能使用yarn ,如果使用其他包会报错
接下来看scripts/checkYarn.js文件,判断在项目中确保脚本通过Yarn 1.x版本来执行,如果不是则给出警告并退出程序

# 判断是否是通过yarn安装的或执行操作
if (!/yarn.js$/.test(process.env.npm_execpath || '')) {
console.warn(
  '\u001b[33mThis repository requires Yarn 1.x for scripts to work properly.\u001b[39m\n'
 )
 # 如果不是给出警告并退出程序
  process.exit(1)
}

1.2、调试 vue-next/scripts/release.js 文件

 vue-next/package.json 文件打开,然后在 scripts 上方,会有debug(调试)按钮,点击后,选择 release。即可进入调试模式 image.png 此时会出现以下截图内容 image.png

:此处我遇到一个报错,报错显示未找到minimist image.png 我的解决方法是通过输入 sudo pnpm install minimist 命令安装了minimist包

更多调试相关内容

2、文件开头的依赖引入和函数声明

2.1、第一部分

通过依赖找到对应安装的依赖 image.png

2.1.1、minimist 命令行参数

minimist 是一个轻量级的JavaScript库,用于在Node.js应用程序中解析命令行参数。它接收一个字符串数组(如 process.argv),并将其解析为一个对象,其中的键是选项名称,而值则是对应的选项值。

例如,如果你的命令行运行情况如下:

node myScript.js -f file.txt --verbose

在这段命令中,process.argv 将包含 ['node', 'myScript.js', '-f', 'file.txt', '--verbose'] 这样的数组。通过使用 minimist,可以将这些参数解析成:

{
  f: 'file.txt',
  verbose: true
}

这样就可以方便地在Node.js脚本中访问和处理这些命令行参数了。

2.1.2、chalk 终端多色彩输出

chalk 是一个流行的Node.js包,主要用于在终端(TTY)输出中添加色彩和样式。它可以让你在命令行界面以丰富多彩的方式展示文本信息,有助于提升代码日志、错误消息或者CLI工具的可读性和美观性。

例如,使用 chalk 可以这样编写颜色化的文本:

const chalk = require('chalk');
console.log(chalk.red('错误!')); // 输出红色的“错误!”
console.log(chalk.green.bold('成功!')); // 输出绿色加粗的“成功!”
console.log(chalk.blue.bgYellow('警告信息')); // 输出蓝色字体、黄色背景的“警告信息”

通过这种方式,可以更直观、清晰地呈现程序运行时的各种状态信息。

2.1.3、semver 语义化版本

semver 是一个遵循语义化版本规范(Semantic Versioning)的Node.js包。语义化版本规范(SemVer)是一种软件版本号命名约定,由主版本号、次版本号和修订号三部分组成(形如:MAJOR.MINOR.PATCH)。该规范规定了版本号更新时的规则,以便开发者理解和管理依赖关系。

semver 包提供了一系列方法来比较、解析、操作符合 SemVer 规范的版本字符串。例如,可以用来判断一个版本是否满足另一个版本的要求(如 ^1.2.3 是否兼容 1.3.0),或是在发布新版本时生成合适的版本号等。

以下是 semver 中的一些典型用法示例:

const semver = require('semver');

// 检查版本号是否满足条件
semver.satisfies('1.2.3', '^1.2'); // 返回 true

// 比较两个版本号
semver.gt('1.2.3', '1.2.2'); // 返回 true,表示'1.2.3'大于'1.2.2'

// 解析版本号
semver.parse('1.2.3-beta.4'); 
// 返回 { version: '1.2.3-beta.4', major: 1, minor: 2, patch: 3, prerelease: ['beta', '4'], build: [] }

// 生成下一个版本号
semver.inc('1.2.3', 'patch'); // 返回 '1.2.4'

2.1.4、 enquirer 交互式询问 CLI

Enquirer 是一个用于 Node.js 的强大交互式命令行用户界面 (CLI) 库 使用 Enquirer 可以简化命令行应用中的用户交互逻辑,并且其界面通常具有良好的用户体验。下面是一个简单的 Enquirer 使用示例:

const Enquirer = require('enquirer');

const prompt = new Enquirer.Prompt({
  type: 'input',
  name: 'username',
  message: 'What is your username?'
});

prompt.run()
  .then(answer => console.log('Answer:', answer))
  .catch(console.error);

在这个例子中,Enquirer 创建了一个简单的问题提示用户输入他们的用户名。当用户提交答案后,程序会输出用户的输入

2.1.5、 execa 执行命令

execa 是一个 Node.js 库,用于执行外部命令并提供更好的控制和错误处理机制。相比于 Node.js 内置的 child_process 模块,execa 提供了更为便捷和强大的API,增强了错误处理功能,同时支持 Promise 和 async/await,使得异步命令执行更加流畅。

execa 主要特点包括:

  1. 支持同步和异步调用。
  2. 自动处理进程退出代码,并将其转换为可抛出的错误。
  3. 支持捕获子进程的标准输出和错误输出流。
  4. 能够轻松传递环境变量和选项给子进程。
  5. 兼容 Windows、Unix-like 系统,并对跨平台问题进行了良好处理。

以下是一个基本的 execa 使用示例:

const execa = require('execa');

(async () => {
  try {
    const { stdout } = await execa('ls', ['-lh', '/usr']);
    console.log(stdout);
  } catch (error) {
    console.error(`执行命令失败: ${error.message}`);
  }
})();

在上述代码中,我们异步地执行了 ls -lh /usr 命令,获取标准输出,并在出错时打印错误信息。

2.2、第二部分

const preId =
  args.preid ||
  (semver.prerelease(currentVersion) && semver.prerelease(currentVersion)[0])
const isDryRun = args.dry
const skipTests = args.skipTests
const skipBuild = args.skipBuild
const packages = fs
  .readdirSync(path.resolve(__dirname, '../packages'))
  .filter(p => !p.endsWith('.ts') && !p.startsWith('.'))

以上描述的内容涉及到了参数解析、文件系统操作和版本号的预发布标识处理

2.3、第三部分

// 跳过的包列表
const skippedPackages = []

// 定义可以使用的版本增量
const versionIncrements = [
  'patch',
  'minor',
  'major',
  // 如果存在预发布标识,则添加预发布相关的版本增量
  ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : [])
]

// 创建一个函数来增量版本号
const inc = i => semver.inc(currentVersion, i, preId)

// 假设我们要发布一个补丁更新
const newVersion = inc('patch')

console.log('Current Version:', currentVersion) // 输出: Current Version: 1.0.0 
console.log('New Version:', newVersion) // 输出: New Version: 1.0.1

更多相关内容可以查看semver文档

2.4、第四部分

// bin 命令
const bin = name => path.resolve(__dirname, '../node_modules/.bin/' + name)

// 它接受命令的路径、命令的参数数组和一个可选的选项对象,确保子进程的输入输出与父进程相同
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

// 返回该包在目录下的绝对路径
const getPkgRoot = pkg => path.resolve(__dirname, '../packages/' + pkg)

// 控制台输出
const step = msg => console.log(chalk.cyan(msg))
2.4.1、bin函数

它接受一个命令行工具的名称(),并返回该工具在目录下的绝对路径。这是通过拼接(当前脚本的目录)、相对路径(指向项目根目录下的目录)和工具名称来实现的,name node_modules/.bin__dirname../node_modules/.bin/node_modules/.bin

2.4.2、run函数
    const run = (bin, args, opts = {}) =>
      execa(bin, args, { stdio: 'inherit', ...opts })

使用库来执行命令行命令。它接受三个参数:execa

  • bin:要执行的命令的路径(通常是通过函数获得的)。bin
  • args:传递给命令的参数数组。
  • opts:一个可选的对象,包含执行命令时的额外选项。

函数内部,被调用,并传入命令路径、参数数组和一个配置对象。配置对象中的确保子进程的输入、输出和错误流与父进程相同,这意味着命令的输出将直接显示在控制台上。execastdio: 'inherit'

2.4.3、dryRun函数
   const dryRun = (bin, args, opts = {}) =>
     console.log(chalk.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts)

它模拟了函数的行为,但并不实际执行命令。它接受与函数相同的参数,并使用在控制台上以蓝色字体打印出将要执行的命令和选项。这通常用于在不实际更改系统的情况下预览脚本的行为。

2.4.4、runIfNotDry函数
  const runIfNotDry = isDryRun ? dryRun : run

这里并没有直接定义一个函数,而是根据变量的值选择或函数。如果为真,则将是函数;否则,它将是函数。这样可以在脚本中方便地切换实际执行和模拟执行

3、main 主流程

3.1、函数大致内容

async function main() {
// 确定目标版本
  let targetVersion = args._[0]
  
// 版本校验
  if (!targetVersion) {
    // no explicit version, offer suggestions
    const { release } = await prompt({
      type: 'select',
      name: 'release',
      message: 'Select release type',
      choices: versionIncrements.map(i => `${i} (${inc(i)})`).concat(['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
  }


  // 运行测试
  // run tests before release
  step('\nRunning tests...')
  
  if (!skipTests && !isDryRun) {
    await run(bin('jest'), ['--clearCache'])
    await run('yarn', ['test', '--bail'])
  } else {
    console.log(`(skipped)`)
  }

  // 更新包版本和依赖项
  // update all package versions and inter-dependencies
  step('\nUpdating cross dependencies...')
  updateVersions(targetVersion)

  // 生成包
  // build all packages with types
  step('\nBuilding all packages...')
  
  // 验证类型声明
  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)`)
  }

  
  // 提交更改
  // generate changelog
  await run(`yarn`, ['changelog'])

  const { stdout } = await run('git', ['diff'], { stdio: 'pipe' })
  if (stdout) {
    step('\nCommitting changes...')
    await runIfNotDry('git', ['add', '-A'])
    await runIfNotDry('git', ['commit', '-m', `release: v${targetVersion}`])
  } else {
    console.log('No changes to commit.')
  }

 // 发布包
 // publish packages
  step('\nPublishing packages...')
  for (const pkg of packages) {
    await publishPackage(pkg, targetVersion, runIfNotDry)
  }

 // 推送到 GitHub
 // push to GitHub
  step('\nPushing to GitHub...')
  await runIfNotDry('git', ['tag', `v${targetVersion}`])
  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- '
        )}`
      )
    )
  }
  console.log()
}
  1. 确定目标版本:它首先尝试从命令行参数中获取版本的目标版本(可能使用类似 for 的内容)。如果未提供任何版本,则会提示用户选择发布类型或输入自定义版本。minimist``args._[0]
  2. 验证版本:使用类似实用程序的内容确保指定的目标版本是有效的语义版本。semver
  3. 确认发布:提示用户确认指定版本的发布。
  4. 运行测试:如果测试不打算跳过(并被选中),则它将继续运行与包关联的任何测试。!skipTests !isDryRun
  5. 更新包版本和依赖项:测试完成后,脚本将更新所有包的版本及其相互依赖项。
  6. 生成包:如果不跳过生成,则使用类型生成所有包。
  7. 验证类型声明:如果未跳过测试,则测试生成的 TypeScript 声明文件(文件)。d.ts
  8. 生成更改日志:运行脚本以生成新版本的更改日志。
  9. 提交更改:检查是否有修改(使用 ),并将它们提交到存储库,并显示一条指示发布版本的消息。git diff
  10. 发布包:循环访问包,如果不打算跳过包,则发布包。
  11. 推送到 GitHub:在 Git 中使用版本标记版本,并将标记和提交推送到源远程存储库。
  12. 试运行和跳过逻辑:输出试运行的结果,并通知发布期间是否跳过了任何包。

3.2、更新相关内容

3.2.1、updatePackage 更新包的版本号

 // update all package versions and inter-dependencies
 step('\nUpdating cross dependencies...')
 updateVersions(targetVersion)
  
function updateVersions(version) {
  // 1. update root package.json
  updatePackage(path.resolve(__dirname, '..'), version)
  // 2. update all packages
  packages.forEach(p => updatePackage(getPkgRoot(p), version))
}

function updatePackage(pkgRoot, version) {
 // 获取package.json文件的绝对路径
  const pkgPath = path.resolve(pkgRoot, 'package.json')
 // 将 package.json 文件读取到内存中,并将字符串转换为 JavaScript 对象 
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  
  // 将包对象的属性更新为传递到函数中的新版本
  pkg.version = version
  // packages.json 中 dependencies 中 vue 相关的依赖修改
  updateDeps(pkg, 'dependencies', version)
  // packages.json 中 peerDependencies 中 vue 相关的依赖修改
  updateDeps(pkg, 'peerDependencies', version)
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}

先执行 pnpm run release --dry 后执行 git diff查看git修改,部分截图如下: image.png

3.2.2、updateDeps 更新内部 vue 相关依赖的版本号

function updateDeps(pkg, depType, version) {
  const deps = pkg[depType]
  if (!deps) return
  Object.keys(deps).forEach(dep => {
    if (
      dep === 'vue' ||
      (dep.startsWith('@vue') && packages.includes(dep.replace(/^@vue\//, '')))
    ) {
      console.log(
        chalk.yellow(`${pkg.name} -> ${depType} -> ${dep}@${version}`)
      )
      deps[dep] = version
    }
  })
}

然后执行yarn release --dry会得到如下内容 image.png

3.3、打包编译所有包

 step('\nBuilding all packages...')
  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)`)
  }

3.4、生成 changelog

 // generate changelog
  await run(`yarn`, ['changelog'])

yarn changgelog对应的是如下 image.png

3.5、提交代码

经过更新版本号后,有文件改动,于是git diff。 是否有文件改动,如果有提交。

git add -A git commit -m 'release: v${targetVersion}'

const { stdout } = await run('git', ['diff'], { stdio: 'pipe' })
  if (stdout) {
    step('\nCommitting changes...')
    await runIfNotDry('git', ['add', '-A'])
    await runIfNotDry('git', ['commit', '-m', `release: v${targetVersion}`])
  } else {
    console.log('No changes to commit.')
  }

3.6、发布包

// publish packages
  step('\nPublishing packages...')
  for (const pkg of packages) {
    await publishPackage(pkg, targetVersion, runIfNotDry)
  }
3.6.1、发布包函数
async function publishPackage(pkgName, version, runIfNotDry) {
  if (skippedPackages.includes(pkgName)) {
    return
  }
  const pkgRoot = getPkgRoot(pkgName)
  const pkgPath = path.resolve(pkgRoot, 'package.json')
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  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
    }
  }
}

执行结果部分代码

image.png

3.7、推送到 github

step('\nPushing to GitHub...')
  await runIfNotDry('git', ['tag', `v${targetVersion}`])
  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- '
        )}`
      )
    )
  }
  console.log()

yarn release --dry后,在终端输出的如下

image.png

4、发布流程

  1. 确认要发布的版本
  2. 执行测试用例
  3. 更新所有包的版本号和内部 vue 相关依赖版本号
    3.1 updatePackage 更新包的版本号
    3.2 updateDeps 更新内部 vue 相关依赖的版本号
  4. 打包编译所有包
  5. 生成 changelog
  6. 提交代码
  7. 发布包
  8. 推送到 github

5、总结

通过本文内容学到了以下内容

  • 熟悉 vuejs 发布流程
  • 学会调试 nodejs 代码
  • 动手优化公司项目发布流程

Vue 3.2的发布是一个结构化和自动化的过程,旨在确保代码质量、文档准确性和社区通知的及时性。这个过程通常涉及几个关键步骤,包括使用Git进行版本控制、运行自动化测试以确保代码稳定性、打包代码以准备生产部署,以及更新官方文档和变更日志来反映新版本的功能和更改。

在这个过程中,vue-release或类似的工具扮演了重要角色。这些工具帮助自动化发布流程中的多个步骤,如推送代码到NPM、生成版本标签、更新变更日志以及可能的社交媒体通知等。通过使用这些工具,Vue团队能够更高效地管理发布流程,同时减少人为错误。

总的来说,学习Vue 3.2的发布流程和vue-release工具,不仅让我们了解了Vue项目的发布实践,还为我们自己的项目提供了宝贵的自动化和流程优化经验。