- 本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
- 第39期 | 如何发布一个 npm 库,自动化管理版本号、生成 changelog、tag 等
学习目标
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 方法(如果工程有多个组件,需要用)
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
1.7 测试发布包
测试方式 1
在当前项目 main.js 中引入 lib 包
测试方式 2
如果要在其他项目中测试当前包,在当前项目终端执行命令 npm link
在其他项目中终端中执行命令 npm link 包名 (包名是 package.json 中的 name 字段),执行成功后,即可正常引入组件使用
主系统推送消息后,显示通知弹窗
2 发布包
2.1 注册 npm 账号
如果没有 npm 账号,需要先注册一个 npm 账号(注册网址)
填写完上面资料,需要进行一次 One-time 安全校验,填上在你的邮箱中收到的验证码,完成注册
2.2 登录 npm 账号
在你的终端上输入 npm login,填上刚注册的账号密码等
注意:如果登录失败,提示以下错误 或者 403 不允许注册错误,查看 npm 是不是淘宝源,切换回 npm 源
// 全局配置npm淘宝镜像源
npm config set registry https://registry.npm.taobao.org
// 全局配置npm官方镜像源
npm config set registry https://registry.npmjs.org
注意:如果提示 402 错误,需要使用 TLS 1.2 以上版本,运行以下命令 npm install -g https://tls-test.npmjs.com/tls-test-1.0.0.tgz
2.3 发布 npm 包
执行 npm publish,发布成功
如果提示以下错误,使用命令 npm publish --access public
注意:如果推送的 npm 包名称,是 @abc/xxx 结构,那么推送会失败,需要在 Organizations 上创建名称为 abc 的组织
发布成功后,在 npm 上看到包已发布成功
如果想要取消发布当前包,需要注意最新策略 npm unpublish
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"
3.2 npm init release-it 原理
npm install release-it 为什么可以选择配置并且创建一个包?
npm init 文档中看出,npm init release-it 等于 npx create-release-it
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
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 文件
至此,完成了自动化流程的所有步骤
3.6 release-it 原理
稍微了解一下实现原理,在 release-it 找到软连接,在入口位置打上断点,跟着断点进行调试
直接看关键代码
// 通过 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 等