如何发布一个 npm 库,自动化管理版本号、生成 changelog 等

839 阅读4分钟

学习目标

    1.如何发布一个 npm 库
    2.如何使用 release-it 提升版本号、生成 changelog 文件,实现自动化流程

1 准备发布包

本次要发布的 npm 插件,是一个集成 websock 的系统通知插件,由主系统下发通知,各个子系统通过插件接收消息并显示通知内容弹窗

1.1 创建工程

创建一个 vue 项目
vue create notice-plugin

1.2 创建组件文件夹

在根目录下,创建 packages 文件夹,通知组件放在此文件夹下,项目的结构如下

项目结构

1.3 修改vue配置文件

const { defineConfig } = require("@vue/cli-service");
const path = require("path");
function resolve(dir) {
  return path.join(__dirname, dir);
}
module.exports = defineConfig({
  transpileDependencies: true,
  lintOnSave: false,
  pages: {
    index: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'index.html'
    }
  },
  // 扩展 webpack 配置,使 packages 加入编译
  chainWebpack: (config) => {
    config.module.rule('js').include.add('/packages').end().use('babel').loader('babel-loader').end().use('less').loader('less-loader').end()
    config.output.filename('[name].js')
    config.output.chunkFilename('[name].[chunkhash:8].js')
  },
  configureWebpack: {
    resolve: {
      alias: {
        '@': resolve('src')
      }
    }
  }
})

1.4 编写组件

考虑到后面扩展需要,通知组件作为一个文件夹,如果后面要添加新的组件,都建独立的分类文件夹,独立文件夹下包括组件和暴露组件的 index.js 文件(如果工程只有一个组件,只需要用这个 index.js 暴露文件,或者用于按需引入)。在 packages 文件夹下需要一个 index.js 文件用于统一暴露组件和install 方法(如果工程有多个组件,需要用)

image.png index.vue 内容如下

<template>
  <div>
    <el-dialog class="system-dialog" title="系统通知" :visible.sync="systemNoticaStatus" width="30%" center>
      <div class="system-dialog">
        <p class="header">{{ systemForm.postTopic }}</p>
        <p class="title">
          <span>发布时间:{{ systemForm.scheduledReleaseTime }}</span>
          <span>发布人:{{ systemForm.createUserName }}</span>
        </p>
        <p class="content">
          {{ systemForm.postContent }}
        </p>
      </div>
      <span slot="footer" class="dialog-footer">
        <el-button size="medium" @click="systemNoticaStatus = false">关闭</el-button>
      </span>
    </el-dialog>
  </div>
</template>

<script>
export default {
  name: 'systemNotifica',
  props: {
    userId: {
      type: String,
      default: ''
    },
    ucToken: {
      type: String,
      default: ''
    },
    wsUrl: {
      type: String,
      default: 'dev'
    }
  },
  watch: {
    ucToken(newValue) {
      console.log('更新 token')
      this.token = newValue
      this.initWebSocket()
    }
  },
  data() {
    return {
      systemNoticaStatus: false,
      systemForm: {
        postTopic: '',
        postContent: '',
        postPageUrl: '',
        scheduledReleaseTime: '',
        createUserName: ''
      },
      locationName: '',
      lockReconnect: false, // 连接失败不进行重连
      maxReconnect: 5, // 最大重连次数 PS:暂不做限制 连不上一直重连
      socket: null,
      timeoutnum: null,
      token: this.ucToken,
      timeout: 30 * 1000,
      timer: null,
      serverTimer: null
    }
  },
  created() {
    if (!this.userId) {
      console.log('缺少userId!')
      return
    }
    if (!this.ucToken) {
      console.log('缺少ucToken!')
      return
    }
    this.locationName = location.host
    this.initWebSocket()
  },
  destroyed() {
    console.log('destroyed销毁定时器...')
    this.closeNotice()
  },
  // 销毁定时器
  beforeDestroy() {
    console.log('beforeDestroy销毁定时器...')
    this.closeNotice()
  },
  mounted() {},
  methods: {
    initWebSocket() {
      try {
        if ('WebSocket' in window) {
          this.socket = new WebSocket(`ws://${this.wsUrl}/systemReminder?userId=${this.userId}&systemName=${this.locationName}&Authorization=${this.token}`)
        } else {
          console.log('您的浏览器不支持websocket')
        }
        this.socket.onopen = this.websocketonopen
        this.socket.onerror = this.websocketonerror
        this.socket.onmessage = this.websocketonmessage
        this.socket.onclose = this.websocketclose
      } catch (e) {
        console.log('reconnect')
        this.reconnect()
      }
    },
    websocketonopen() {
      console.log('WebSocket连接成功', this.socket.readyState)
      this.start()
    },
    websocketonerror(e) {
      console.log('WebSocket连接发生错误', e)
      this.reconnect()
    },
    websocketonmessage(e) {
      let data = JSON.parse(e.data)
      console.log('收到消息...', data)
      if (data.code === '200') {
        let msg = JSON.parse(data.msg)
        this.systemForm = Object.assign({}, msg, {
          scheduledReleaseTime: this.formatTime(msg.scheduledReleaseTime, 'yyyy-MM-dd HH:mm')
        })
        this.systemNoticaStatus = true
        console.log('消息对象...', msg)
        this.websocketsend({
          id: msg.id,
          userId: this.userId,
          systemName: this.locationName
        })
      } else if (data.code === '500') {
        this.socket.close()
        this.lockReconnect = true
        console.log(data.errorMsg)
        this.$emit('refreshToken')
        return
      }
      // 消息获取成功,重置心跳
      this.start()
    },
    websocketclose(e) {
      console.log('connection closed (' + e.code + ')')
      this.reconnect()
    },
    websocketsend(data) {
      console.log('发送消息', JSON.stringify(data))
      this.socket.send(JSON.stringify(data))
    },
    reconnect() {
      if (this.lockReconnect || this.maxReconnect <= 0) return
      this.timeoutnum && clearTimeout(this.timeoutnum)
      console.log('尝试重连')
      this.timeoutnum = setTimeout(() => {
        console.log('重连中')
        this.initWebSocket()
      }, this.timeout)
    },
    start() {
      this.reset()
      this.timer = setTimeout(() => {
        console.log('发送心跳')
        // onmessage拿到返回的心跳就说明连接正常
        this.socket.send(JSON.stringify({ heart: 1 }))
        this.serverTimer = setTimeout(() => {
          // 如果超过一定时间还没响应(响应后触发重置),说明后端断开了
          this.socket.close()
        }, this.timeout)
      }, this.timeout)
    },
    reset() {
      this.timer && clearTimeout(this.timer)
      this.serverTimer && clearTimeout(this.serverTimer)
    },
    closeNotice() {
      this.reset()
      this.lockReconnect = true
      this.timeoutnum && clearTimeout(this.timeoutnum)
      this.socket.close() // 离开路由之后断开websocket连接
    },
    formatTime(time, fmt) {
      if (!time) return ''
      else {
        const date = new Date(time)
        const o = {
          'M+': date.getMonth() + 1,
          'd+': date.getDate(),
          'H+': date.getHours(),
          'm+': date.getMinutes(),
          's+': date.getSeconds(),
          'q+': Math.floor((date.getMonth() + 3) / 3),
          S: date.getMilliseconds()
        }
        if (/(y+)/.test(fmt)) {
          fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length))
        }
        for (const k in o) {
          if (new RegExp('(' + k + ')').test(fmt)) {
            fmt = fmt.replace(RegExp.$1, RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length))
          }
        }
        return fmt
      }
    }
  }
}
</script>

<style lang="less">
.system-dialog {
  .header {
    font-size: 16px;
    font-weight: 500;
    color: #333;
    text-align: center;
    margin: 0 0 5px;
  }
  .title {
    text-align: center;
    span:nth-of-type(1) {
      margin-right: 20px;
    }
  }
  .content {
    min-height: 150px;
    padding: 10px;
    background: #efefef;
  }
}
</style>

组件内的 index.js 内容如下

// 导入组件,组件必须声明 name
import systemNotifica from './src'

// 为组件提供 install 安装方法,供按需引入
systemNotifica.install = (Vue) => Vue.component(systemNotifica.name, systemNotifica)

// 导出组件
export default systemNotifica

packages 文件夹下的 index.js 内容如下

// 导入组件
import systemNotifica from './systemNotifica'

// 组件列表
const components = [systemNotifica]

// 定义 install 方法,接收 Vue 作为参数
const install = function (Vue) {
  // 判断是否安装
  if (install.installed) return
  // 遍历注册全局组件
  components.map((component) => Vue.component(component.name, component))
}

// 判断是否是直接引入文件
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export default {
  // 导出的对象必须具有 install,才能被 Vue.use() 方法安装
  install,
  // 以下是具体的组件列表
  systemNotifica
}

1.5 修改 package.json 文件

修改成库发布模式

{
  // npm 包名
  "name": "@gpdi_ds_notice/notice-plugin-vue2",
  // 版本号
  "version": "0.0.1",
  // 描述
  "description": "wss系统通知插件",
  // 是否是私有的
  "private": false,
  // 作者
  "author": "frsoul",
  // 上传到 npm 的文件,这里我们只上传打包后的 lib 文件夹,配合 main 字段使用
  "files": [
    "lib"
  ],
  // 搜索关键词
  "keywords": [
    "gpdi_ds_notice"
  ],
  // 通过impor引用包时指定的入口文件
  "main": "./lib/ds_platform_plugin.umd.min.js",
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    // 构建为lib库模式,文件名为ds_platform_plugin,输出文件夹为lib文件,入口packages/index.js
    "build:lib": "vue-cli-service build --target lib --name ds_platform_plugin --dest lib packages/index.js"
  }
}

1.6 构建发布包

在终端执行npm run build:lib
构建成功后,生成 lib 文件夹,发布到 npm 上的包也就是 lib image.png

1.7 测试发布包

测试方式 1
在当前项目 main.js 中引入 lib 包

image.png

测试方式 2
如果要在其他项目中测试当前包,在当前项目终端执行命令 npm link

image.png

在其他项目中终端中执行命令 npm link 包名 (包名是 package.json 中的 name 字段),执行成功后,即可正常引入组件使用

image.png

主系统推送消息后,显示通知弹窗 image.png

2 发布包

2.1 注册 npm 账号

如果没有 npm 账号,需要先注册一个 npm 账号(注册网址image.png 填写完上面资料,需要进行一次 One-time 安全校验,填上在你的邮箱中收到的验证码,完成注册 image.png

2.2 登录 npm 账号

在你的终端上输入 npm login,填上刚注册的账号密码等

image.png 注意:如果登录失败,提示以下错误 或者 403 不允许注册错误,查看 npm 是不是淘宝源,切换回 npm 源

// 全局配置npm淘宝镜像源
npm config set registry https://registry.npm.taobao.org
// 全局配置npm官方镜像源
npm config set registry https://registry.npmjs.org

image.png

注意:如果提示 402 错误,需要使用 TLS 1.2 以上版本,运行以下命令 npm install -g https://tls-test.npmjs.com/tls-test-1.0.0.tgz

2.3 发布 npm 包

执行 npm publish,发布成功 image.png

如果提示以下错误,使用命令 npm publish --access public

image.png

注意:如果推送的 npm 包名称,是 @abc/xxx 结构,那么推送会失败,需要在 Organizations 上创建名称为 abc 的组织 image.png image.png

发布成功后,在 npm 上看到包已发布成功 image.png

如果想要取消发布当前包,需要注意最新策略 npm unpublish image.png

3 release-it 提升版本号、生成 changelog

每次的发布步骤都是更新版本号、打 tag、推送 npm、记录版本更新日志,这些工作都是重复的,release-it 就是解决上面的问题,release-it 地址

3.1 安装 release-it

npm install release-it
// 选择.release-it.json
// 安装完成后,在 scripts 中自动生成了命令 "release": "release-it"

image.png

3.2 npm init release-it 原理

npm install release-it 为什么可以选择配置并且创建一个包?
npm init 文档中看出,npm init release-it 等于 npx create-release-it

image.png

create-release-it,从源码了解到,通过 create-release-it 可以让我们选择配置,把配置写入到 package.json 文件中,最后执行npm install release-it --save-dev

import fs from 'fs';
import util from 'util';

const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);

const PACKAGE_CONFIG = 'package.json';
//.....

let manifest = {};
try {
  manifest = JSON.parse(await readFile(PACKAGE_CONFIG));
  hasManifest = true;
} catch (err) {}
 
if (hasManifest) {
  manifest.scripts = manifest.scripts || {};
  if (!('release' in manifest.scripts)) {
    manifest.scripts.release = 'release-it';
    isManifestChanged = true;
  }
}
if (isManifestChanged) {
  await writeFile(PACKAGE_CONFIG, JSON.stringify(manifest, null, '  ') + EOL);
}
await execa('npm', ['install', 'release-it', '--save-dev'], { stdio: 'inherit' });

3.3 运行 release-it

执行 npm run release,选择你需要的项,可以看到 release 可以提升 version、 打 commit、发布npm、打 tag、push 代码
上面的步骤虽然打印了 changelog 信息,但是没有地方记录,那么出来了 release-it 的插件 conventional-changelog

image.png

3.4 安装 @release-it/conventional-changelog

npm install @release-it/conventional-changelog -D

3.5 配置 @release-it/conventional-changelog

新建文件 .release-it.json,进行一些简单的配置,在这里可以实现配置 git、npm、github、gitlab 四个模块
除了在配置上加上 false 可以实现跳过此流程,也可以通过命令行实现 npx release-it --no-npm.publish,跳过发布步骤
配置是布尔值的,都可以加上 no- 前缀将其值设置为 false,也可以通过命令行,实现跳过 push 流程,npx release-it --no-git.push
甚至可以通过命令行设置 commit 默认内容,npx release-it --ci --git.commitMessage=" 更新版本${version} "

{
  "github": {
    // 执行流程的时候,是否允许在 git 创建一个 release ${version} 分支
    "release": false
  },
  "git": {
    // 执行流程的时候,是否跳过 publish 流程步骤
    "push": false,
    // 自定义流程中 commit的信息
    "commitMessage": "release: v${version}"
  },
  // 执行流程的时候,是否跳过 publish 流程步骤
  "npm": {
    "publish": false
  },
  "hooks": {
    // 更新完成后的提示语
    "after:bump": "echo 更新版本成功"
  },
  "plugins": {
    "@release-it/conventional-changelog": {
      // 生成的文件名称
      "infile": "CHANGELOG.md",
      // 开启 release-it 提供的版本选择
      "ignoreRecommendedBump": true,
      "preset": {
        // 使用自定义模板模式
        "name": "conventionalcommits",
        "header": "# 📋 更新历史 \n\n",
        // 自定义的模板
        "types": [
          { "type": "feat", "section": "✨ Features | 新功能" },
          { "type": "fix", "section": "🐛 Bug Fixes | Bug 修复" },
          { "type": "perf", "section": "⚡ Performance Improvements | 性能优化" },
          { "type": "revert", "section": "⏪ Reverts | 回退" },
          { "type": "chore", "section": "📦 Chores | 其他更新" },
          { "type": "docs", "section": "📝 Documentation | 文档" },
          { "type": "style", "section": "💄 Styles | 风格", "hidden": true },
          { "type": "refactor", "section": "♻ Code Refactoring | 代码重构" },
          { "type": "test", "section": "✅ Tests | 测试" },
          { "type": "build", "section": "👷‍ Build System | 构建" },
          { "type": "ci", "section": "🔧 Continuous Integration | CI 配置" }
        ]
      }
    }
  }
}

配置完成之后,再次执行 npm run release,可以看到生成了一份 CHANGELOG.md 文件

image.png

至此,完成了自动化流程的所有步骤

3.6 release-it 原理

稍微了解一下实现原理,在 release-it 找到软连接,在入口位置打上断点,跟着断点进行调试 image.png

直接看关键代码

// 通过 cosmiconfig 库获取根目录中的 .release-it.json 文件配置,根据配置来执行相应操作
import { cosmiconfigSync } from 'cosmiconfig';
const getLocalConfig = ({ file, dir = process.cwd() }) => {
  let localConfig = {};
  if (file === false) return localConfig;
  const explorer = cosmiconfigSync('release-it', {
    searchPlaces,
    loaders
  });
  const result = file ? explorer.load(file) : explorer.search(dir);
  if (result && typeof result.config === 'string') {
    throw new Error(`Invalid configuration file at ${result.filepath}`);
  }
  debug({ cosmiconfig: result });
  return result && _.isPlainObject(result.config) ? result.config : localConfig;
};

// 获取插件,给 git、npm、github、gitlab、version 模块绑定各自的方法
// 加载外部插件,如:@release-it/conventional-changelog
const load = async pluginName => {
  let plugin = null;
  try {
    const module = await import(pluginName);
    plugin = module.default;
  } catch (err) {
    try {
      const module = await import(path.join(process.cwd(), pluginName));
      plugin = module.default;
    } catch (err) {
      // In some cases or tests we might need to support legacy `require.resolve`
      const require = createRequire(process.cwd());
      const module = await import(url.pathToFileURL(require.resolve(pluginName, { paths: [process.cwd()] })));
      plugin = module.default;
    }
  }
  return [getPluginName(pluginName), plugin];
};
export let getPlugins = async (config, container) => {
  const enabledExternalPlugins = await _.reduce(
    context.plugins,
    async (result, pluginConfig, pluginName) => {
      // 加载插件
      const [name, Plugin] = await load(pluginName);
      // ...
      // 启用 conventional-changelog 插件
      disabledPlugins.push(..._.intersection(pluginNames, _.castArray(Plugin.disablePlugin(options))));
      return result;
    },
    []
  );
  const enabledPlugins = await pluginNames.reduce(async (result, plugin) => {
    // ...
  }, []);
  return [enabledPlugins, enabledExternalPlugins];
};

// 获取包名称、最新版本号、changelog 日志并保存
// getName、getLatestVersion、getChangelog 方法是在上一步绑定到模块中的
const reduceUntil = async (collection, fn) => {
  let result;
  for (const item of collection) {
    if (result) break;
    result = await fn(item);
  }
  return result;
};
const name = await reduceUntil(plugins, plugin => plugin.getName());
const latestVersion = (await reduceUntil(plugins, plugin => plugin.getLatestVersion())) || '0.0.0';
const changelog = await reduceUntil(plugins, plugin => plugin.getChangelog(latestVersion));
config.setContext({ name, latestVersion, version, changelog });

// 遍历插件,并且执行插件 plugin['release'] 方法,release 方法最终调用了 inquirer 库,实现提示框
const runLifeCycleHook = async (plugin, name, ...args) => {
  if (plugin === _.first(plugins)) await runHook('before', name);
  await runHook('before', plugin.namespace, name);
  // 执行 release 方法
  const willHookRun = (await plugin[name](...args)) !== false;
  if (willHookRun) {
    await runHook('after', plugin.namespace, name);
  }
  if (plugin === _.last(plugins)) await runHook('after', name);
};
plugins = [...internal, ...external];
for (const hook of ['release', 'afterRelease']) {
  for (const plugin of plugins) {
    await runLifeCycleHook(plugin, hook);
  }
}

4 总结

通过本文,学习到了以下的知识

1. 学习到如何发布 npm 包
2. 学习到 npm init 原理、release-it 原理
3. 学习到 release-it 提升版本号,生成 changelog 等