Node.js 折腾日记:开发自用的 npm 包(上)
我个人不是 Node.js 的深度用户,哪怕前端开发用了很久的 Vue,也只是略懂皮毛,大部分时间都是跟着开发文档走。
最近做一些知识梳理的时候,想着还是可以用 TypeScript 做一个自用的包,封装常用的工具函数(一直以来都是用的独立 js 文件),便于跨项目使用。
很多时候做的东西看着好像在当下没什么用,实际执行的时候,却能学到很多东西。我大部分时间都是按需学习,有需要的时候拿着先用起来,对于基础知识、底层架构缺乏了解,这样的行为恰恰能弥补这方面的不足。
不废话了,开始正文。教程非常简单,就是一个 npm 包的基础实现,掌握了基础再扩展就没有太大的难度了。
创建项目
# 创建项目目录
mkdir Mor_Npm
# 进入项目目录
cd Mor_Npm
# 初始化项目,直接使用默认设置
yarn init -y
# 安装 TypeScript
yarn add typescript --dev
# 初始化 TypeScript 配置文件
npx tsc --init
直接用代码写流程感觉特别方便,不用麻烦的思考排版。
这里直接使用默认配置初始化了一个项目,同时安装好 TypeScript 开发依赖(对各种依赖不是很了解的看这篇文章 https://wqmoran.com/npm-vs-yarn-understanding-global-and-project-dependencies/)并初始化配置文件,此时的 package.json 文件内容如下:
{
"name": "Mor_Npm",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.4"
}
}
这里需要做一些修改,直接一步到位,如下:
{
"name": "moranutils",
"version": "1.0.0",
"description": "A simple utils library",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"author": "wqmoran",
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.4"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w"
}
}
name
:这里修改了一下,默认设置使用的是目录名,建议都是小写,所以改了一下。type
: 定义模块的类型,"module"
表示该项目使用 ES 模块。这里特意说明一下,这个其实也是没有深入了解 Node.js 的时候会很迷糊的地方。Node.js 本身是服务端运行环境,默认是 CommonJS 模块,但是开发的包也会用于前端项目,所以这里需要设置为前端支持的 ES 模块。main
: 使用 CommonJS 模块系统(如 Node.js 默认的模块系统)时项目的入口文件,当其他项目引入这个包时,会默认加载这个文件(dist
是编译的时候自动生成的默认目录)。module
: 使用 ES 模块系统(如现代前端构建工具,例如 Webpack、Rollup 等)时的入口文件。这里也特意说明一下,不同模块系统可以设定不同的入口文件,如果我们开发的包想要在两种环境下都能使用,这两个入口就需要同时提供。这篇文章主要是针对浏览器,所以使用 ES 模块。types
: 指定 TypeScript 的类型定义文件,使得 TypeScript 用户在使用此包时能有类型提示。scripts
: 定义了可以运行的脚本命令。build
: 运行 TypeScript 编译器(tsc
)以编译项目。watch
: 运行 TypeScript 编译器在观察模式下(tsc -w
),当源文件变化时自动重新编译。
package.json 文件修改完成后,接着修改 tsconfig.json 文件,默认内容我就不放了,很多注释内容。我直接放修改后的配置,也是一步到位,如下:
{
"compilerOptions": {
// 指定 ECMAScript 目标版本,这里是 ES2017
"target": "ES2017",
// 使用最新的 ES 模块标准
"module": "ESNext",
// 允许导入符合 ES6 模块规范的模块,确保与 CommonJS 模块兼容
"esModuleInterop": true,
// 强制文件名的大小写必须一致,防止在大小写敏感的操作系统中出现问题
"forceConsistentCasingInFileNames": true,
// 启用所有严格类型检查选项
"strict": true,
// 指定输出文件夹,这里是 './dist'
"outDir": "./dist",
// 启用此选项会生成相应的 '.d.ts' 声明文件
"declaration": true,
// 是否跳过检查 '.d.ts' 声明文件,设置为 false 时,会检查所有的声明文件 '*.d.ts',以确保类型正确
"skipLibCheck": false
},
// 指定需要编译的文件和目录,这里是 'src' 目录下的所有文件
"include": [
"src/**/*"
]
}
每个配置我都给出了对应的注释,基本还是能看懂的,有几个单独拎出来说一下。
target
: 初始配置是es2016
,这里版本大家可以根据自己包里使用不同 ES 版本特性定义,想进一步了解各版本兼容情况可以直接去 https://www.w3schools.com/js/js_versions.asp 查看。module
: 这个默认是commonjs
,逻辑就和上面 package.json 一样,这里修改为ESNext
。大小写都可以,我这里用大写是因为枚举值给我的结果是这样的,那肯定用枚举值给我的。declaration
: 这个选项挺重要的,我们开发出来的包要提供代码提示和类型检查,需要有配套的 .d.ts 声明文件,这个选项可以为我们自动生成声明文件。include
: 指定编译哪些目录下的 TS 文件,如果不设置则会编译项目中所有的 TS 文件(普遍共识是将 TS 文件放在src
目录中)。
上面两个配置文件的修改是开发自有 npm 包的最小配置,就我看各种教程配置文件的经验来说,冗余的配置内容会给学习的人造成干扰,增加理解成本。上面这些配置的核心就两个:
- 开发的包能用于 Node.js 服务器环境,也要能在浏览器中使用
- 毕竟使用 TypeScript 开发,提供完善的类型声明,提供好的使用体验
开发第一个工具
项目基础环境已经搭建好了,这里封装一个获取当前时间字符串的函数作为演示。
创建 src 目录,同时创建 indes.ts、time.ts 两个 .ts 文件,此时项目目录结构如下:
.
├── node_modules
├── package.json
├── src
│ ├── index.ts
│ └── time.ts
├── tsconfig.json
└── yarn.lock
index.ts 及编译后的 index.js 会作为入口文件,是的,就是 package.json 中设置的。关于入口文件的存在意义,这里做一下简单的说明:
- 模块化:通过设置一个入口文件,可以控制包的导出内容,使外部只能访问到想公开的函数或类。这样有助于管理和维护包的接口,确保模块化的结构。
- 简洁性:使用
index.ts
作为入口文件可以在项目中更方便地使用包。当导入包时,可以不需要指定具体文件,例如使用import { something } from 'your-package'
而不是import { something } from 'your-package/some/path/to/file'
。 - 易于维护:随着项目的发展,可能会增加更多的功能和模块。有了一个固定的入口文件,即使内部结构变化,对于使用者的导入路径不会发生变化,提高了代码的可维护性。
- 约定:这已经成为了 JavaScript 和 TypeScript 社区的一种普遍做法,遵循这样的约定可以让包更加符合开发者的预期。
为 time.ts 编辑如下内容:
// src/time.ts
interface ICurrentTime {
getDateTime: () => string;
}
const currentTime: ICurrentTime = {
getDateTime: () => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1; // 月份从 0 开始,所以加 1
const day = now.getDate();
const hours = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
// 通过 `padStart(2, '0')` 确保月、日、时、分、秒都是两位数
return (
`${year}-${month.toString().padStart(2, "0")}-${day
.toString()
.padStart(2, "0")} ` +
`${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`
);
},
};
export { currentTime };
在这段代码中,currentTime
是一个对象,遵循了 ICurrentTime
接口的定义,提供了 getDateTime
方法,获取当前时间的字符串表达。
再来是 index.ts:
// src/index.ts
export * from "./time";
总的来说,这行代码的作用是将 time.ts
文件中所有公开的导出(如前面定义的 currentTime
对象)重新导出,使得这些导出可以通过主入口文件 index.ts
访问。这样,当其他模块或包导入自有包时,可以直接通过包名来访问 time.ts
中定义的所有公开内容,无需指定具体的文件路径。
然后我们执行一下编译:
yarn build
编译完成后的目录结构如下:
.
├── dist
│ ├── index.d.ts
│ ├── index.js
│ ├── time.d.ts
│ └── time.js
├── node_modules
├── package.json
├── src
│ ├── index.ts
│ └── time.ts
├── tsconfig.json
└── yarn.lock
编译完成后,我们可以看 time.d.ts 文件中的内容,会发现之前定义的 ICurrentTime
放到了文件中,这个就是编译时自动生成的类型声明。
以上,就是开发一个自有包的最小项目结构,理解了这套逻辑,就可以在这个基础上开发更复杂多样的 npm 包,更可以发布自己的包,但这个不是本文的重点了。
接下来会说明如何在本地开发的时候使用包,因为具体使用的时候会涉及到一些构建工具,都在一篇文章中太长了,所以分开写。