学习下 vue.js 是如何发布版本的?
前言
尤大宣布了 vue 3 版本在 2022 年 2 月 7 日成为默认安装的版本(也就是 tag
由 next
改为了 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
,没有则输出警告退出进程。
为什么会先执行 preinstall
命令呢,这是 npm scripts 约定的一套命名匹配规则,当我们命令以 pre
、post
开头都会被匹配执行,pre
开头可以看成是命令的前置钩子,post
开头为后置钩子。例如我定义了一条命令 myscript
,那么 premyscript
、postmyscript
也会被执行,执行顺序:premyscript
=> myscript
=> postmyscript
。
{
"scripts": {
"premyscript": "在 myscript 前执行",
"myscript": "xxx",
"postmyscript": "在 myscript 后执行"
}
}
此外,scripts 还有其他内置的生命周期钩子,具体见 scripts 字段说明 npm-scripts[1]
运行调试
点击调式脚本 release 脚本命令,调试 release.js
源码。
release.js 源码
我们看到在最后面执行了 main 函数,该函数就是入口,直接从这开始一步一步看。
总体流程
main 内部执行流程主要做了以下事情:
-
确定和校验要发布的版本号
-
判断是否需要执行测试用例
-
更新所有 vue 相关的版本号
-
打包编译所有包及测试类型声明文件
-
生成 changelog
-
提交代码 git add、git commit
-
发布新版本包到 npm
-
代码 push 到 github 仓库
接下来我们分步看具体代码,每一步从 main 函数开始看
1. 确定和校验要发布的版本号
确定版本号,这里分为两种情况:
-
执行时指定了版本号,则
targetVersion
变量为该值,例如:yarn run release 1.3.20
, 则targetVersion
值为1.3.20
。 -
未指定版本号,则提供选项供选择(选项的版本号是在 currentVersion 的基础上递增);执行时若指定
preid
参数,例如yarn run release --perid=alpha.1
, 则会多出一些先行版本号选项,如下图:
校验版本号,通过调用 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
相关代码
// 给终端字符串设置样式
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, null, 2) + '\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]),跟之前对比有如下更改:
-
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 || '')) {
// ...
} -
release.js
文件里面由yarn
运行的命令改为由pnpm
运行,例如async function main() {
// generate changelog
// 现在
await run(`pnpm`, ['run', 'changelog'])
// 之前
await run(`yarn`, ['changelog'])
} -
在之前 流程 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 发布版本包的总体流程,学以致用,以后我们再开发一些包或工具的时候可以借鉴借鉴!!!
参考资料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