Node.js 折腾日记:开发自用的 npm 包(上)

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 包的最小配置,就我看各种教程配置文件的经验来说,冗余的配置内容会给学习的人造成干扰,增加理解成本。上面这些配置的核心就两个:

  1. 开发的包能用于 Node.js 服务器环境,也要能在浏览器中使用
  2. 毕竟使用 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 包,更可以发布自己的包,但这个不是本文的重点了。

接下来会说明如何在本地开发的时候使用包,因为具体使用的时候会涉及到一些构建工具,都在一篇文章中太长了,所以分开写。

Node.js 折腾日记:开发自用的 npm 包(下)
紧接上篇文章,接下来我们再创建一个项目来模拟如何使用开发的 npm 包。