vue 发布源码 release

133 阅读3分钟

1 学习目标

在这里,学习尤大大是怎么发布vuejs的,通过阅读releaseJs,将获得什么?

  1. 学习 vue 发布流程
  2. 学习调试 node 代码
  3. 学以致用

2 使用 npm 安装依赖

安装依赖前,先简单了解一下 npm install 的原理

/*
简略介绍一下npm install的实现原理
  1 当我们进行npm install的时候,如果定义了preinstall,会先执行此钩子。
  2 确定依赖模块
    2.1 确定devDependencies中指定的模块
    2.2 工程相当于是树的根节点,每个首层依赖模块就是根节点下面的一个子节点,npm开启多进程从子节点开始遍历子节点下的所有节点。
  3 获取模块
    3.1 下载模块之前,先判断版本,如果存在描述文件package-lock.json,直接从此文件中获取即可。如果没有则从仓库中获取,比如package.json中定义某个包是^1.1.1,那么npm会直接在仓库中找1.x.x的最新版本
    3.2 下载模块,在上一步中获取到模块的压缩包地址,也就是package-lock.json中的resolved字段,npm会先检查本地npm缓存是否存在,如果存在直接取,如果没有才从仓库下载
    3.3 查找模块的依赖,如果有依赖则回到3.1开始执行,没有则停止
  4 模块dedupe
    4.1 在上一步获取到的是一个完整的依赖,但是有可能会有大量重复的模块,比如同时有两个模块都依赖了loadsh,这时候,会有一个扁平化的处理过程,它会遍历所有节点,将模块放到node_modules中,并将重复模块丢弃
    4.2 重复模块:指的是名称一样并且semver兼容(语义化版本,npm使用此工具处理版本相关的工作,比如比较两个版本大小lt、gt),如果重复模块的版本存在兼容版本,那么就会得到一个兼容版本,如果不兼容,就会保留下来两个版本,这样就可以多余模块就会被去掉。
  5 安装模块,更新工程中的node_modules,并执行模块中的生命周期函数(preinstall、install、postinstall)
  6 生成package-lock.json描述文件
*/
{
  "script": {
    "preinstall": "node ./scripts/checkYarn.js"
  }
}

执行 npm install 后,发现有报错的,因为有一个安装前的钩子函数 preinstall node ./scripts/checkYarn.js ,检查是否使用 yarn 安装,所以只能使用 yarn 安装依赖。更多关于npm script钩子的详细介绍

/*
  当我们执行npm i和yarn,
  process.env 环境变量 npm_execpath(执行路径)
  npm i -- /usr/local/lib/node_modules/npm/bin/npm-cli.js
  yarn -- /usr/local/lib/node_modules/yarn/bin/yarn.js
*/
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)
}

3 文件依赖库介绍

// 获取命令行参数
const args = require('minimist')(process.argv.slice(2))
// 文件模块
const fs = require('fs')
// 路径模块
const path = require('path')
// 控制台样式
const chalk = require('chalk')
// 语义化控制模块
const semver = require('semver')
// 获取package.js version
const currentVersion = require('../package.json').version
// 命令行交互式库
const { prompt } = require('enquirer')
// 执行命令库
const execa = require('execa')

3.1 minimist 命令行参数解析库( minimist地址

先了解一下 minimist 的使用

const args = require('minimist')(process.argv.slice(2))
/* 
  获取参数
  process.argv: 返回启动node进程时传入的命令行参数的集合数组。
    process.argv[0]: 启动进程文件路径 '/usr/local/bin/node'
    process.argv[1]: 正在执行的js文件路径
    ...: 任何其他命令行参数,以空格分割
  require('minimist'): 命令行参数解析工具。接收两个参数
    1.参数数组 
    2.opt对象 
      opt.string: ['x'] 将参数名为x的值转为字符串
      opt.alias: {x: 'c'} 将字符串参数名称映射到别名,同时保留原参数名
      opt.default: {a: '6'} 添加默认的参数
      opt.boolean 默认为false 为true 会将 '--hello' ==> hello: true
      opt.-- 默认为false 为true 会将 '--' 后面的值放到 ==> '--': ['f']
    例: require('minimist')(['-x', '3',  '-y', '4', '-z=5', '-ghj', 'ccc', '--hello', 'bbb', 'dd', '--', 'f'])
    { 
      _: [ 'dd', 'f'],
      x: '3',
      c: '3',
      y: 4,
      z: 5,
      g: true,
      h: true,
      j: 'ccc',
      hello: 'bbb',
      a: '6'
    }
*/

3.2 chalk 终端控制台字符串输出美化库( chalk地址

语法:
    console.log(chalk.red.bold.bgWhite('Hello World'))
      相等
    console.log(chalk.rgb(255,0,0).bold.bgRgb(255,255,255)('Hello World'))
      Hello World 加粗,字体颜色是红色,背景颜色是白色
  支持模板使用
    console.log(chalk`{red.bold.bgWhite Hello World}`)
      相等
    console.log(chalk`{rgb(255,0,0).bold.bgRgb(255,255,255) Hello World}`)

3.3 semver 语义化版本控制库( semver地址

语义化版本控制模块
版本号的基本规则
  版本号一般有三个部分,以.隔开,就像X.Y.Z,其中
  	X:主版本号,不兼容的大改动
  	Y:次版本号,功能性的改动
  	Z:修订版本号,问题修复
  package ~和^的区别
  	~: 安装x.y.z中z的最新版本,也就是只更新修订版本号
    ^: 安装x.y.zy:z的最新版本,也就是不更新主版本号
	语法:
	  valid(v): 返回解析后的版本,如果无效返回null
	  prerelease(v): 返回预发布组件的数组,如果不存在,则返回null。
    	例:prerelease('1.2.3-alpha.1') -> ['alpha', 1]
	   clean(version): 如果可能,将字符串清理为有效的 semver
	   inc(v, release, type): 返回由发布类型递增的版本,也就是生成一个版本如果无效则返回null。
    	例:inc('1.2.3', 'prerelease', 'beta') -> '1.2.4-beta.0'

3.4 enquirer 命令行交互式库( enquirer地址

参数介绍
  type:表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor
  name: 存储当前问题回答的变量
  message:问题的描述
  default:默认值;
  choices:列表选项,在某些type下可用,并且包含一个分隔符(separator);
  validate:对用户的回答进行校验;
  filter:对用户的回答进行过滤处理,返回处理后的值;
  transformer:对用户回答的显示效果进行处理(如:修改回答的字体或背景颜色),但不会影响最终的答案的内容;
  when:根据前面问题的回答,判断当前问题是否需要被回答;
  pageSize:修改某些type类型下的渲染行数;
  prefix:修改message默认前缀;
  suffix:修改message默认后缀。
  语法 
    基本使用
    	const { prompt } = require('enquirer')
    	prompt(参数数组).then(answer => { console.log(answer) })
    另一种使用方式
    	const { Input } = require('enquirer');
    	const prompt = new Input({
        message: 'What is your username?',
        initial: 'jonschlinkert'
      });
      prompt.run()
        .then(answer => console.log('Answer:', answer))
        .catch(console.log);

3.5 execa 命令行交互式库( execa地址

execa(file, arguments?, options?)
  第一个参数是一个字符串,运行脚本的命令
  第二个参数是一个参数数组,给这个脚本传入的参数
  第二个参数是一个参数对象 cwd: 子进程的工作目录、stdio: 标准输入等等
  例1: const { stdout } = await execa('echo', ['unicorns']); 
  console.log(stdout) ==> 'unicorns'2: const { stdout } = await execa('ls', {cwd: path.resolve(__dirname)}); // 打印目录列表

4 声明变量介绍

/*
  用于定义beta版本
  yarn run release --preid=beta
  preId = beta
*/
const preId =
  args.preid ||
  (semver.prerelease(currentVersion) && semver.prerelease(currentVersion)[0])
/*
  空跑,只是输出结果
  yarn run release --dry
  dry = true
*/
const isDryRun = args.dry
/*
  跳过测试
  yarn run release --skipTests
  skipTests = true
*/
const skipTests = args.skipTests
/*
  跳过打包
  yarn run release --skipBuild
  skipBuild = true
*/
const skipBuild = args.skipBuild
// 跳过包,填写之后,会跳过此包的发布
const skippedPackages = []
/*
  版本递增 patch 补丁 minor 次要的 major主要的
  preId存在的话,增加beta版选择
*/
const versionIncrements = [
  'patch',
  'minor',
  'major',
  ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : [])
]
/*

5 函数介绍

/*
  semver.inc('1.2.3', 'prerelease', 'beta')
  '1.2.4-beta.0'
*/
const inc = i => semver.inc(currentVersion, i, preId)
// 获取执行文件路径,全文只有一处使用了bin('jest')
const bin = name => path.resolve(__dirname, '../node_modules/.bin/' + name)
// 真正执行的终端命令函数 yarn build --release
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)
/*
  如: yarn run release --dry
  如果dry存在,执行空跑,打印信息,否则真正跑终端命令
  存在的意义:有时候需要看命令结果,但是又不是真正的提交发布
*/
const runIfNotDry = isDryRun ? dryRun : run
// 获取packages目录-pkg目录路径的函数
const getPkgRoot = pkg => path.resolve(__dirname, '../packages/' + pkg)
// 控制台输出信息函数
const step = msg => console.log(chalk.cyan(msg))

6 main主流程介绍

6.1 主流程概括

async function main() {
  1.明确要发布的版本
  2.发布之前先执行测试
  // run tests before release
  step('\nRunning tests...')
  3.更新全部包的版本(包括packages目录下的文件)和dependencies版本
  // update all package versions and inter-dependencies
  step('\nUpdating cross dependencies...')
  4.打包编译全部包
  // build all packages with types
  step('\nBuilding all packages...')
  5.生成changelog日志文件
  // generate changelog
  6.提交代码,但未推送到GitHub
  step('\nCommitting changes...')
  7.发布包
  // publish packages
  step('\nPublishing packages...')
  8.推送代码到GitHub
  // push to GitHub
  step('\nPushing to GitHub...')
}

6.2 明确要发布的版本

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?`
})
// 如果选择false,返回
if (!yes) {
  return
}

image.png image.png

6.3 发布之前先执行测试

// run tests before release
  step('\nRunning tests...')
  // 不是跳过测试 并且 不是空跑
  if (!skipTests && !isDryRun) {
    // 清除jest 缓存
    await run(bin('jest'), ['--clearCache'])
   	// 运行测试用例
    await run('yarn', ['test', '--bail'])
  } else {
    console.log(`(skipped)`)
  }

6.4 更新全部包的版本(包括packages目录下的文件)和dependencies版本

// update all package versions and inter-dependencies
step('\nUpdating cross dependencies...')
// 更新本身的package.json版本号和所有package目录下的版本号
updateVersions(targetVersion)

function updateVersions(version) {
  // 1. update root package.json
  // 更新package.json中的dependencies和peerDependencies的vue版本
  updatePackage(path.resolve(__dirname, '..'), version)
  // 2. update all packages
  // 更新packages目录下的所有符合条件目录的package.json的vue版本
  packages.forEach(p => updatePackage(getPkgRoot(p), version))
}

function updatePackage(pkgRoot, version) {
  // 获取package文件路径
  const pkgPath = path.resolve(pkgRoot, 'package.json')
  // 读取package文件
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
  pkg.version = version
  // 更新package dependencies 目录的vue版本
  updateDeps(pkg, 'dependencies', version)
  // 更新package peerDependencies 目录的vue版本
  updateDeps(pkg, 'peerDependencies', version)
  // 更新后的package的重新写入。JSON.stringify 2 文本在每个级别缩进两个空格
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}

function updateDeps(pkg, depType, version) {
  const deps = pkg[depType]
  if (!deps) return
  // 遍历package deps对象下,key为vue 或者 key是以@vue开头并且需要匹配packages数组下和替换掉@vue/开头的字段严格匹配
  // 问题: 为什么要判断packages目录下是否存在@vue
  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
    }
  })
}

执行后,可以看到控制台输出信息 image.png

6.5 打包编译全部包

// 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)`)
}

6.6 生成changelog日志文件

// generate changelog
/*
  conventional-changelog-cli 生成日志库
  命令:conventional-changelog -p angular -i CHANGELOG.md -s
  生成CHANGELOG.md更新日志 
*/
await run(`yarn`, ['changelog'])

6.7 提交代码,但未推送到GitHub

// git diff 查看git的修改
const { stdout } = await run('git', ['diff'], {
  stdio: 'pipe'
})
// 如果git对比有差异,git add\git commit 添加变更进去本地仓库。
if (stdout) {
  step('\nCommitting changes...')
  // git add -A
  await runIfNotDry('git', ['add', '-A'])
  // git commit -m 'release: v${targetVersion}'
  await runIfNotDry('git', ['commit', '-m', `release: v${targetVersion}`])
} else {
  console.log('No changes to commit.')
}

image.png git diff 查看一下更改的内容 image.png

6.8 发布包

// publish packages
step('\nPublishing packages...')
// 发布包
for (const pkg of packages) {
  await publishPackage(pkg, targetVersion, runIfNotDry)
}
async function publishPackage(pkgName, version, runIfNotDry) {
  // skippedPackages现在为空,跳过的包
  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
    // 当vue为 3.x时,删除名为next的tag。我们安装vue3时,npm i vue@next
    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

6.9 推送代码到GitHub

// push to GitHub
step('\nPushing to GitHub...')
// 打tag
await runIfNotDry('git', ['tag', `v${targetVersion}`])
// 推送tag到远程 git push origin refs/tags/vx.x.x
await runIfNotDry('git', ['push', 'origin', `refs/tags/v${targetVersion}`])
// 推送更改到远程
await runIfNotDry('git', ['push'])
// yarn release --dry
if (isDryRun) {
  console.log(`\nDry run finished - run git diff to see package changes.`)
}
// 如果skippedPackages存在,则表示此包没有发布。
if (skippedPackages.length) {
  console.log(
    chalk.yellow(
      `The following packages are skipped and NOT published:\n- ${skippedPackages.join(
        '\n- '
      )}`
    )
  )
}
console.log()

image.png

7 总结

现有项目基于 gitlab 的 webhook 实现自动发布程序,对比 vue 的发布流程,拓展了视野
熟悉 vue 的发布流程
了解一些 node 库