使用 Rollup 构建并发布一个 Typescipt NPM包
前言
最近使用 Github Actions 做 CICD 的时候,用到了企业微信提供的 Github 机器人,其提供了一套基于 Webhook 机制的消息推送功能,可以在构建打包或PR发起的时候将相关信息转发到群聊中。
关于 Webhook 的介绍推荐这篇文章:https://segmentfault.com/a/1190000020249988
根据官方提供的文档,其实也可以自己写代码,来处理一些自定义场景,比如转发 Sentry 告警到群聊中(Sentry 本身也提供了这种 Webhook 机制)。
为了方便使用和嵌入项目中去,我把官方文档发送消息的处理做了一个封装,并加上了 Typescript 类型支持,并以 NPM 包的形式进行发布(webhook-chatbot)。下面是过程中的一些记录:
需要用到的工具
- npm 账号:将构建后的 lib 包发布到 npm 仓库
- rollup:将所有使用到的模块打包到一个或多个最终的发布文件中
- typescript:提供类型检测和代码提示
- eslint + prettier:代码风格校验和自动格式化
项目初始化
大概要用到的东西理清楚后,就可以新建一个新项目了
-
创建包文件夹
mkdir messaging-robot && cd messaging-robot
-
yarn 初始化包
yarn init -y
-
git 初始化并关联 Github 远程仓库
git init git add . && git commit -m "init" git remote add origin <Git Repository Url> git push -u origin master
使用 Typescript
安装
yarn add typescipt -D && npx tsc --init
配置
生成 tsconfig.json文件,用来配置 ts 的编译选项
// tsconfig.json
{
"compilerOptions": {
"declaration": true, // 生成 .d.ts 声明文件
"esModuleInterop": true // // 提供两个helper函数__importStar和__importDefault,通过为导入内容创建命名空间,实现CommonJS和ES模块之间的互操作性
"lib": ["ESNext"], // 最新的 es 语法提示,同时不引入 dom 相关的定义文件
// "module": "esnext", // 源代码使用模块类型
"moduleResolution": "Node", // 能在 esm 项目中解析 cjs 模块
"outDir": "./", // 编译后的文件存到到哪个目录
"removeComments": false, // 编译删除注释,这里选择保留
"target": "es6", // 目标 ECMAScript 版本, Node v6之后就已经支持 es6 了
},
// 只编译 src 目录下的文件
"include": [
"src"
],
"exclude": ["node_modules","lib"],
"ts-node": {
"esm": true
}
}
编写测试源码并导出
在 src 目录下编写 ts 代码,并在 src/index.ts 中进行导出
// src/index.ts
export const a = 0
export const fn = () => { console.log(a) }
export default {
a,
fn
}
使用 tsc 命令测试构建
生成 js 文件和 .d.ts 文件,如下图
使用 ESlint + Prettier
安装
yarn add eslint prettier -D
配置
eslint
可以使用 npx eslint --init
命令行的方式自动安装规则集、插件和生成配置文件
在这里选择直接手动安装
yarn add @typescript-eslint/eslint-plugin @typescript-eslint/parser
新建 .eslintrc 配置文件
{
"env": {
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": [
"@typescript-eslint"
],
"parser": "@typescript-eslint/parser",
"root": true
}
prettier
yarn add -D eslint-config-prettier eslint-plugin-prettier
添加 .pretteric.js 文件,这里只是根据个人的习惯进行配置
// .pretteric.js
module.exports = {
printWidth: 80,
semi: false,
singleQuote: true,
trailingComma: 'none',
arrowParens: 'avoid'
}
修改 .eslintrc 文件
// .eslintrc
"env": {
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"plugins": [
"@typescript-eslint"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"root": true
效果
上面配置完毕,可以看到就生效了
使用 Rollup 打包
这个项目的模块化采用了 ESM 规范,并且是 TS 编写的,需要进行打包,这里我采用了 Rollup 作为构建工具。
Rollup 使用流程
浏览器环境
-
若无需考虑浏览器兼容性
- 书写 esm 代码
- rollup 从项目入口文件出发,递归识别 esm 模块
- 最终打包成一个或多个 bundle.js
- 浏览器通过
<script type="module"></srcipt>
引入
-
若考虑浏览器兼容性
相对而言就会比较复杂,需要用额外的 polyfill 库,或者结合 Webpack 使用
npm 包
- 书写 esm 代码 (也可以使用 cjs,需要插件
@rollup/plugin-commonjs
) - rollup 从项目入口文件出发,递归识别 esm 模块
- 配置输出多种格式的模块,如 esm、cjs、umd、amd
- 最终打包成一个或多个 bundle.js
Rollup 的优点和使用场景
- 轻量级、配置简单、容易上手
- 支持 Tree Shaking
这些特点决定了 Rollup 适合用来构建一些 JS 库(如 Vue、React等等),或高版本无需向下兼容的程序。与 Webpack 的臃肿相比,Rollup 打包后的代码普遍体积更小,也更容易看懂(Webpack 为了实现 Tree Shaking 会注入很多自定义的代码)
我们想要发布的 npm 包只在 Node 环境下使用,就适合使用 Rollup 对代码进行处理,甚至都不需要用到 Babel 进行 ES 语法的向下兼容。下面就正式开始吧!
安装
yarn add rollup -D
基本配置
安装必要的处理插件
@rollup/plugin-node-resolve
: 查找和打包node_modules中的第三方模块@rollup/plugin-commonjs
: 将 CommonJS 转换成 ES2015 模块供 Rollup 处理@rollup/plugin-typescript
: 解析TypeScriptrollup-plugin-delete
: 打包前删除之前的 bundle
yarn add @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript @rollup-plugin-babel rollup-plugin-delete -D
新建 rollup.config.js 文件
// rollup.config.js
import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import typescript from '@rollup/plugin-typescript'
import del from 'rollup-plugin-delete'
export default [
{
input: 'src/index.ts',
output: {
file: 'lib/index.cjs.js',
format: 'cjs',
entryFileNames: '[name].cjs.js'
},
plugins: [
del({
targets: ['lib/**/*']
}),
nodeResolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.json'
})
],
external: [...] // 外部引用的库,不要打包,用于处理 peerDependencies
},
{
input: 'src/index.ts',
output: {
file: 'lib/index.esm.js',
format: 'esm',
entryFileNames: '[name].esm.js'
},
plugins: [
nodeResolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.json'
})
],
external: [...]
}
打包命令
往 package.json 文件中新增 scripts 打包命令
{
...
"scripts": {
"build": "rollup -c",
}
...
}
打包命令
往 package.json 文件中新增 scripts 打包命令
{
...
"scripts": {
"build": "rollup -c",
}
...
}
入口文件处导出模块
这是我这个项目中代码的目录结构
只要在 index.ts 入口文件导出模块即可
// src/index.ts
import WXRobot from './robots/WXRobot'
import DingRobot from './robots/DingRobot'
export { WXRobot, DingRobot }
运行打包
使用 yarn build
执行构建命令,就可以得到我们的目标代码产物了
发布 npm 包
自动化 NPM Scripts
npm 包在发布的时候会触发一些钩子,可以在这些阶段做一些自动化的处理,详情参考 npm scripts
下面是这次使用到的一些配置,主要也就是代码的 lint 校验 和 自动 build
"scripts": {
"build": "rollup -c",
"format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"",
"lint": "eslint . --ext .ts",
"prepublishOnly": "npm run lint && npm run build",
"preversion": "npm run lint"
},
注册账户
前往 npm 官网 创建一个 npm 账号
发布
一般来说,我们会将构建好的 npm 包直接托管在 npm 的公有 registry 上, 这样每个人都可以自由地安装使用。有时候,可能只是一些内部公共业务的抽离,显然不能让所有人都有权限下载,这时候就需要考虑私有化部署一个 registry,再将 npm 发布到内网,实现不同项目的代码库共享。
公有
命令行使用 npm login
登录 npm 账号
使用 npm publish
进行发布
私有
- 部署 verdaccio
verdaccio 是一个私有仓库搭建工具,搭配镜像源管理工具 nrm 使用,可以方便地切换不同的 npm 源使用。
npm i -g verdaccio nrm
安装完毕可以直接运行 verdaccio 开启私有 npm 服务,也可以对 verdaccio 进行一些启动配置(/users/用户名/.config/verdaccio/config.yaml
)
使用 pm2 守护进程去启动 verdaccio,避免断开连接后服务关闭
npm install -g pm2
pm2 start verdaccio
启动后,我们打开浏览器http://127.0.0.1:4873,就可以看到了
- 发布 npm 包
在发布前,要先确保我们当前的 npm 源不是公有源
使用 nrm add local http://localhost:4873
添加一个本地源 ,使用 nrm ls
命令查看
使用 nrm use local
使用我们的本地源
剩下的操作就跟使用公有源是一样的了,这里不再重复。
遇到的问题和处理
1. console 对象在 ts 文件中不存在
'console' is not defined.eslint(no-undef)
✖ 1 problem (1 error, 0 warnings)
JavaScript 有很多种运行环境,常见的浏览器和 NodeJS,除此之外还有很多软件系统使用 JavaScript 作为脚本引擎,比如 RN、PostgreSQL等。而这些运行环境可能并不存在console
这个对象。另外在浏览器环境下会有window
对象,而 Node 下没有;在 Node 下会有process
对象,而浏览器环境下没有。
所以在配置文件应该指明程序的目标环境:
// .eslintrc
{
......
"env": {
"node": true
},
......
}
保存后重新执行检查,就不会有任何报错信息了。
https://morning.work/page/maintainable-nodejs/getting-started-with-eslint.html
2. 顶层 awiat 的使用
根据语法规范,await
只能出现在 async
函数内部,否则会报错。如果我们就想在一个 NodeJS 模块里面使用顶层 await
,有如下几种办法:
-
IIFE
(async () => { const res = await fn() })()
-
NodeJS v14.8 后可用
Node.js v14.8 版本中允许直接使用顶层 await,替代了之前使用 --harmony-top-level-await 标识的方案。
$node --harmony-top-level-await app.js
但这只能在 ESM 中使用,也即是说要保证每个 js 文件都是 ESM 模块
上面是针对 js 文件的做法,如果是 ts 的话,则有:
最新的 es 提案(ES2022)支持了 top level await
,允许 await
关键字能在模块 modules 的顶层正常工作。(在 class 代码块或非 async
函数仍不支持),根据 eslint 的提示,我们只需要在 tsconfig.json 里面设置一下即可。
3. npm 构建的五种依赖
dependencies
指定了项目运行所需要的依赖
devDependcies
指定了项目开发所需要的依赖
peerDependencies
当开发一个模块的时候,如果当前模块A与所依赖的模块B同时依赖一个第三方模块C
有几种情况需要讨论:
-
A 和 B 所依赖的 C 模块版本相同
这种情况下将只安装一份模块C,对应的 node_mudules目录结构为:
. ├── A.js ├── node_modules │ ├── C │ │ ├—— C.js │ │ └── package.json │ ├── B │ │ ├—— B.js │ │ └── package.json ├── package.json
-
A 和 B 所依赖的 C 模块版本不相同
这种情况下将会安装两份模块C,对应的 node_mudules目录结构为
. ├── A.js ├── node_modules │ ├── B │ │ ├── B.js │ │ ├── node_modules │ │ │ └── C2 │ │ │ ├── C2.js │ │ │ └── package.json │ │ └── package.json │ └── C1 │ ├── C1.js │ └── package.json └── package.json
大多数情况下,这也没什么问题,C模块的两个版本可以共存。但是,当 B 与 C 的依赖关系暴露给用户时,就可能会出现问题。
典型的场景是插件,比如我们的项目 A 中用到了 Vue2.x (C1),我们还安装了 element-plus(B)。但我们知道,element-plus 本身是基于 Vue3.x(C2) 构建的。这时,用户要是将 2.x 版本的 Vue 实例传给 element-plus,就可能会出现问题。
因此,需要一种机制,在依赖安装的时候,如果 B 和 C 一起安装,那么 C 必须是 3.x版本。
peerDependencies
字段,就是用来供插件(element-plus)指定其所需要的依赖的版本。可以通过peerDependencies
字段来限制,使用 element-plus 必须依赖 vue3.x"peerDependencies": { "vue": "^3.2.0" },
bundledDependencies
指定发布的时候会被一起打包的模块
optionalDependencies
如果一个依赖模块可以被使用, 同时你也希望在该模块找不到或无法获取时npm
继续运行,你可以把这个模块依赖放到optionalDependencies
配置中。这个配置的写法和dependencies
的写法一样,不同的是这里边写的模块安装失败不会导致npm install
失败。
Semver 版本规范
它们的值都是一个对象,对象中的每个成员,分别由模块名和对应的版本要求组成,表示依赖的模块及其版本范围
版本号遵循 semver 规范(主版本号.次版本号.修订号)
- 固定版本:如1.2.0,安装时只安装指定的版本
- 波浪号:如~1.2.0,安装次版本号的最新修订版本,即1.2.x
- 插入号:如^1.2.0,安装主版本号的最新次版本、修订版本,即1.x.x
4. url.parse() 已废弃
项目中用到了 https
模块发送 post
请求到指定的 webhook url,原生 node 提供了 http.request
,需要传入解析过后的参数:
https.request({
hostname: 'example.org' ,
protocol: 'https',
path: '/foo/',
port: '443',
method: 'post',
headers: {
'Content-Type': 'application/json'
},
})
于是就使用到了 url
模块的 parse
方法进行处理,会出现提示说已经被废弃:
url.parse()
方法使用一种宽松的非标准算法来解析 url 字符串,可能会引入安全问题,已经确定的有主机名欺骗以及用户名和密码处理不当的问题。
官方推荐使用 URL
内置类进行 url 解析,它提供了一组符合 WHATWG 规范 的 API。
const url = new URL('https://example.org:8080/foo/?id=1&name=who')
console.log(url);
URL {
href: 'https://example.org:8080/foo/?id=1&name=who',
origin: 'https://example.org:8080',
protocol: 'https:',
username: '',
password: '',
host: 'example.org:8080',
hostname: 'example.org',
port: '8080',
pathname: '/foo/',
search: '?id=1&name=who',
searchParams: URLSearchParams { 'id' => '1', 'name' => 'who' },
hash: ''
}
https://nodejs.org/api/url.html#class-url
5. 生产环境下缺少 ts-lib 报错
其实这跟上面提到的 peerDependencies
也有关系,我们在安装依赖 @rollup/plugin-typescipt
的时候,命令行有提示过我们:
rollup 官方插件仓库也有提示: Note that both
typescript
andtslib
are peer dependencies of this plugin that need to be installed separately.
6. ?? 和 || 的区别
a ?? b
a || b
相同点:用法相同,都是前后为值,中间用运算符连接。根据前面的值判断最终值
不同点:使用 ??
时,当 a 为 null
或 undefined
时,才会返回 b;使用 ||
时,a 会先进行隐式类型转换,变成 Boolean
后进行判断,若为 true
,则返回a,否则返回b
总体来说,空值合并运算符更适合在不知道变量是否有值的时候使用
总结
关于 Rollup ,其实还是比较好上手的,它配置起来足够轻量足够简洁。但这里只是简单地处理了一下 typescript,并生成两种不同模块的入口文件。如果在浏览器中使用,还需要考虑非 js 文件的识别、静态文件处理、ES语法降级、sourcemap、代码压缩、热更新等等。大多数我们能想到的功能, Rollup 都有对应的 plugin 去做处理,并且将它们收录到了官方的插件库中,使用的时候非常方便。
最后,关于这篇文章用到的代码,还有这个库本身,我都把它们放到 github 上了,感兴趣的话可以点这里,如果有哪些不对的地方欢迎指正,感谢。
参考
https://segmentfault.com/a/1190000008832423
https://www.ruanyifeng.com/blog/2016/10/npm_scripts.html
https://docs.npmjs.com/cli/v8/using-npm/scripts