skip to content
寻找莉莉丝

实现一个简单的 node 应用之 todo list

/ 15 min read / 次阅读

前言

学习目标:实现一个简单的 node 应用:todo list。

功能主要有:

  • 添加新任务
  • 清空任务列表
  • 展示所有任务
  • 操作任务
    • 修改任务标题
    • 修改任务状态
    • 删除单个任务

一、环境安装

  1. node
  2. npm

二、项目初始化

  1. 新建文件夹 mkdir node-todo-1
  2. 进入文件目录 cd node-todo-1
  3. 初始化包文件 npm init -y
  4. 新建文件 touch index.js

三、commander.js

官网链接:https://github.com/tj/commander.js/blob/master/Readme_zh-CN.md

1. 安装依赖

yarn add commander

2. 选项

// index.js
const { program } = require("commander");
program.option("-a --add", "add an item").option("-d, --delete", "delete an item");

program.parse(process.argv);

.option(flags, desc) 用于定义选项

3. 看看效果

-h 是 —help 的缩写,默认用于显示帮助列表。

node index.js -h
Inkedimage-20210716220142724_LI.jpg

4. 命令

看着文档,复制粘贴,摸索一下。

// index.js
const { program } = require("commander");
// 选项
program.option("-a --add", "add an item").option("-d, --delete", "delete an item");
// 命令
program
	.command("add") // 在终端中输入:node index add task1 task2 task3
	.argument("<tasks...>", "taskNameList") // 多参数处理
	.description("The cmd is used to add a task or more tasks.") // 命令描述
	.action((tasks) => {
		console.log(tasks); // tasks is arguments list
		// 将参数列表合并处理成字符串
		const words = tasks.join(" ");
		console.log(words);
	});
program.parse(process.argv);

.command(nameAndArgs) 用于定义命令,命令名字和输入的参数可以写在一起!

.argument(arg) 用于定义参数,注意多参数的情况要用’<args…>‘的形式!

.description(desc) 是命令的描述

.action(fn) 是输入命令后执行的回调

  • 执行看看,node index add task1 task2 task3

    image-20210716224055264.png

四、回调的分离

将 index.js 重命名为 cli.js,区别在于,将 .action() 的内容分离到 index.js 中统一管理。

index.js 中存放各种 api。(添加新任务、清除任务列表、展示所有任务等等)

1. Linux 命令

cat ~/.todo 表示:查看根目录下 .todo 文件中的内容

rm ~/.todo 表示:删除跟目录下的 .todo 文件

2. 添加新任务(功能 1)

  • 引入index.js,使用其中的方法。
// cli.js
const { program } = require("commander");
const api = require("./index");

// 选项
program.option("-a --add", "add an item").option("-d, --delete", "delete an item");
// 命令
program
	.command("add") // 在终端中输入:node index add task1 task2 task3
	.argument("<tasks...>", "taskNameList") // 多参数处理
	.description("The cmd is used to add a task or more tasks.") // 命令描述
	.action((tasks) => {
		const words = tasks.join(" ");
		api.add(words); // 执行add方法,添加新任务到数据库!
	});
program
	.command("clear")
	.description("The cmd is used to clear all tasks.")
	.action((tasks) => {
		// 将参数列表合并处理成字符串
		const words = tasks.join(" ");
		console.log(words);
	});
program.parse(process.argv);
  • add() 方法是 index.js 中的方法,它定义了触发 add Commander 命令时的处理逻辑。
    • 读取数据库文件 fs.readFile()
    • 添加一个新任务
    • 将新任务写入文件 fs.writeFile()
// index.js
const homedir = require("os").homedir(); // 获取home目录
const home = process.env.HOME || homedir; // 先从系统变量中获取
const path = require("path");
const dbPath = path.join(home, ".todo"); // 数据库路径(拼接而来的)
const fs = require("fs");

module.exports.add = (taskContent) => {
	// 1.读取文件
	fs.readFile(dbPath, { flag: "a+" }, (err, data) => {
		if (err) {
			console.log(err);
		} else {
			let list;
			try {
				// 此处的 data.toString() 应是一个JSON字符串,需要转换为真的数组!
				list = JSON.parse(data.toString());
			} catch (error) {
				// 如果报错,说明没有这样的数据,就创建一个新的数组!
				list = [];
			}
			// 2.添加一个任务
			const task = {
				title: taskContent,
				completed: false,
			};
			list.push(task); // 将新建的任务推进 list 中
			// 3.将任务存储到文件
			const string = JSON.stringify(list); // 将 list 转换为 JSON 字符串
			// 将数据写入文件中
			fs.writeFile(dbPath, string, (err) => {
				if (err) {
					console.log(err);
					return;
				}
			});
		}
	});
};

Node.js 内置了很多模块,可以获取宿主环境中的某些信息。文档查阅:https://devdocs.io/

  • os 模块是操作系统模块,os.homedir() 可以获取系统的根目录路径。 image-20210717181717304.png

  • process.env.HOME 可以获取进程中设置的 HOME 变量的对应路径。 image-20210717181939813.png

  • path 模块是路径模块,path.join(…path) 可以拼接多个路径。 image-20210717182129247.png

  • fs 模块是文件模块,

最后,尝试一下:

image-20210717182629993.png

3. 方法封装之面向接口编程

在“添加任务”的三个步骤中,希望一个步骤就是一条执行语句,而非现在这样一堆代码冗在那里!

也就是说先设计好接口,然后封装代码,以后使用某个功能时,只需要调用对应的接口即可。

  • 数据库中存放读写操作:
    • 注意 fs.readFile() 以及 fs.writeFile() 都是异步操作,因此不可以直接 return 结果。
    • 利用 Promise 对象改写异步操作,
    • 并且在出错时,直接 return reject(err);(直接返回失败的理由,不执行下面的代码。)
// db.js
const homedir = require("os").homedir();
const home = process.env.HOME || homedir;
const path = require("path");
const dbPath = path.join(home, ".todo");
const fs = require("fs");

const db = {
	// 1. 读取文件
	read(path = dbPath) {
		return new Promise((resolve, reject) => {
			fs.readFile(path, { flag: "a+" }, (err, data) => {
				if (err) return reject(err);

				let list;
				try {
					list = JSON.parse(data.toString());
				} catch (error) {
					list = [];
				}
				resolve(list);
			});
		});
	},
	// 2. 写入文件
	write(list, path = dbPath) {
		return new Promise((resolve, reject) => {
			const string = JSON.stringify(list);
			fs.writeFile(path, string, (err) => {
				if (err) return reject(err);
				resolve();
			});
		});
	},
};

module.exports = db;
  • 接口调用
// index.js
const db = require("./db");

module.exports.add = async (taskContent) => {
	// 1.读取文件
	const list = await db.read();
	// 2.添加一个任务
	list.push({ title: taskContent, completed: false });
	// 3.将任务写入文件
	await db.write(list);
};

最后,尝试一下:

image-20210717205834780.png

4. 清除任务列表(功能 2)

直接写入一个空的数组即可:

// cli.js
const { program } = require("commander");
const api = require("./index");

// 选项
program.option("-a --add", "add an item").option("-d, --delete", "delete an item");
// 命令
// 命令1:添加新任务
program
	.command("add") // 在终端中输入:node index add task1 task2 task3
	.argument("<tasks...>", "taskNameList") // 多参数处理
	.description("The cmd is used to add a task or more tasks.") // 命令描述
	.action((tasks) => {
		const words = tasks.join(" ");
		api
			.add(words)
			.then(() => {
				console.log("添加成功!");
			})
			.catch((err) => {
				console.log("添加失败!错误原因:" + err);
			});
	});
// 命令2:清空任务列表
program
	.command("clear")
	.description("The cmd is used to clear all tasks.")
	.action(() => {
		api
			.clear()
			.then(() => {
				console.log("清除成功!");
			})
			.catch((err) => {
				console.log("清除失败!错误原因:" + err);
			});
	});
program.parse(process.argv);
// index.js
const db = require("./db");

// 添加新任务
module.exports.add = async (taskContent) => {
	// 1.读取文件
	const list = await db.read();
	// 2.添加一个任务
	list.push({ title: taskContent, completed: false });
	// 3.将任务写入文件
	await db.write(list);
};

// 清空任务列表
module.exports.clear = async (title) => {
	await db.write([]);
};

最后,尝试一下:

image-20210717215643573.png

5. 展示所有任务(功能 3)

process.argv 表示用户输入在终端的参数个数,官网描述更好:

image-20210717214843313.png

当用户仅输入 node cli.js 两项参数时,展示所有任务:

// cli.js
// 用户直接调用 node cli.js
if (process.argv.length === 2) {
	void api.showAll();
} else {
	program.parse(process.argv);
}
// inidex.js
// ...
// 展示所有任务
module.exports.showAll = async () => {
	// 1. 读出之前的任务
	const list = await db.read();
	// 2. 打印直接的任务
	list.forEach((task, index) => {
		console.log(`${task.completed ? "[x]" : "[_]"} ${index + 1} -> ${task.title}`);
	});
};

五、inquirer

inquirer 是一个用户与命令行交互工具。https://github.com/SBoudrias/Inquirer.js#readme

1. 安装依赖

yarn add inquirer

当展示出所有的任务后,实际上需要上下移动光标,然后执行后续的操作,因此就要借助 inquirer 库来实现这个目标!

2. 操作任务(功能 4)

// index.js
const db = require("./db");
const inquirer = require("inquirer");

// 添加新任务
module.exports.add = async (taskContent) => {
	// 1.读取文件
	const list = await db.read();
	// 2.添加一个任务
	list.push({ title: taskContent, completed: false });
	// 3.将任务写入文件
	await db.write(list);
};

// 清空任务列表
module.exports.clear = async () => {
	await db.write([]);
};

// 展示所有事项
module.exports.showAll = async () => {
	// 1. 读出之前的任务
	const list = await db.read();
	// 2. 打印之前的任务
	// list.forEach((task, index) => {
	//   console.log(`${task.completed ? '[x]' : '[_]'} ${index + 1} -> ${task.title}`);
	// });

	// 发起询问
	inquirer
		.prompt({
			type: "list",
			name: "index",
			message: "你想要执行哪一项任务?",
			choices: [
				{ name: "+ 添加任务", value: "-2" },
				{ name: "- 退出", value: "-1" },
				...list.map((task, index) => {
					return {
						name: `${task.completed ? "[x]" : "[_]"} ${index + 1} -> ${task.title}`,
						value: index,
					};
				}),
			],
		})
		.then((answer) => {
			const index = parseInt(answer.index);
			if (index >= 0) {
				// 选中了一个任务
				inquirer
					.prompt({
						type: "list",
						name: "action",
						message: "请选择操作",
						choices: [
							{ name: "退出", value: "quit" },
							{ name: "已完成", value: "completed" },
							{ name: "未完成", value: "incomplete" },
							{ name: "改标题", value: "updateTitle" },
							{ name: "删除", value: "remove" },
						],
					})
					.then((answer) => {
						console.log(answer.action);
						switch (answer.action) {
							case "completed":
								list[index].completed = true;
								db.write(list);
								break;
							case "incomplete":
								list[index].completed = false;
								db.write(list);
								break;
							case "updateTitle":
								inquirer
									.prompt({
										type: "input",
										name: "title",
										message: "请输入新的标题",
										default: list[index].title, // 原标题
									})
									.then((answer) => {
										list[index].title = answer.title;
										db.write(list);
									});
								break;
							case "remove":
								list.splice(index, 1);
								db.write(list);
								break;
						}
					});
			} else if (index === -2) {
				// 添加任务
				inquirer
					.prompt({
						type: "input",
						name: "title",
						message: "请添加新任务标题",
					})
					.then((answer) => {
						list.push({
							title: answer.title,
							completed: false,
						});
						db.write(list);
					});
			}
		});
};
image-20210718142731832.png

3. 代码优化

// index.js
const db = require("./db");
const inquirer = require("inquirer");

// 1. 添加新任务
module.exports.add = async (taskContent) => {
	// 1.读取文件
	const list = await db.read();
	// 2.添加一个任务
	list.push({ title: taskContent, completed: false });
	// 3.将任务写入文件
	await db.write(list);
};

// 2. 清空任务列表
module.exports.clear = async () => {
	await db.write([]);
};

// 3.2.2 添加新任务
function askForAddNewTask(list) {
	inquirer
		.prompt({
			type: "input",
			name: "title",
			message: "请添加新任务标题",
		})
		.then((answer) => {
			list.push({
				title: answer.title,
				completed: false,
			});
			db.write(list);
			console.log("添加成功!");
		});
}

// 3.2.1.1 设置已完成状态
async function setCompletedState(list, index) {
	list[index].completed = true;
	await db.write(list);
	console.log("当前任务已完成!");
}
// 3.2.1.2 设置未完成状态
async function setIncompleteState(list, index) {
	list[index].completed = false;
	await db.write(list);
	console.log("当前任务待完成...");
}
// 3.2.1.3 修改标题
function updateTitle(list, index) {
	inquirer
		.prompt({
			type: "input",
			name: "title",
			message: "请输入新的标题",
			default: list[index].title, // 原标题
		})
		.then((answer) => {
			list[index].title = answer.title;
			db.write(list);
			console.log("标题更新成功!");
		});
}
// 3.2.1.4 移除任务
async function removeTask(list, index) {
	list.splice(index, 1);
	await db.write(list);
	console.log("删除成功!");
}

// 3.2.1 后续操作
function askForNextAction(list, index) {
	const actions = {
		setCompletedState,
		setIncompleteState,
		updateTitle,
		removeTask,
	};
	inquirer
		.prompt({
			type: "list",
			name: "action",
			message: "请选择操作",
			choices: [
				{ name: "退出", value: "quit" },
				{ name: "已完成", value: "setCompletedState" },
				{ name: "未完成", value: "setIncompleteState" },
				{ name: "改标题", value: "updateTitle" },
				{ name: "删除", value: "removeTask" },
			],
		})
		.then((answer) => {
			const currentAction = actions[answer.action];
			currentAction && currentAction(list, index);

			// switch (answer.action) {
			//   case 'setCompletedState':
			//     setCompletedState(list, index);
			//     break;
			//   case 'setIncompleteState':
			//     setIncompleteState(list, index);
			//     break;
			//   case 'updateTitle':
			//     updateTitle(list, index);
			//     break;
			//   case 'removeTask':
			//     removeTask(list, index);
			//     break;
			// }
		});
}

// 3.2 打印之前的任务 + 后续操作
function displayTasks(list) {
	inquirer
		.prompt({
			type: "list",
			name: "index",
			message: "你想要执行哪一项任务?",
			choices: [
				{ name: "+ 添加任务", value: "-2" },
				{ name: "- 退出", value: "-1" },
				...list.map((task, index) => {
					return {
						name: `${task.completed ? "[x]" : "[_]"} ${index + 1} -> ${task.title}`,
						value: index,
					};
				}),
			],
		})
		.then((answer) => {
			const index = parseInt(answer.index);
			if (index >= 0) {
				// 3.2.1 选中了一个任务,执行后续操作
				askForNextAction(list, index);
			} else if (index === -2) {
				// 3.2.2 添加新任务
				askForAddNewTask(list);
			}
		});
}

// 3. 展示所有事项
module.exports.showAll = async () => {
	// 3.1 读出之前的任务
	const list = await db.read();
	// 3.2 打印之前的任务
	displayTasks(list);
};

六、代码发布

1. 设置 shebang

让用户自动执行 node,参考:https://zhuanlan.zhihu.com/p/262456371

在 cli.js 中的首行添加一段 shebang 代码:

// cli.js
#!/usr/bin/env node

2. 配置 package.json

{
	"name": "cpc-node-todo-1",
	"bin": {
		"cpc-todo": "./cli.js"
	},
	"files": ["cli.js", "db.js", "index.js"],
	"version": "0.0.3",
	"main": "index.js",
	"license": "MIT",
	"dependencies": {
		"commander": "^8.0.0",
		"inquirer": "^8.1.2"
	}
}
  • name 字段表示,包的名称,以后通过这个名称来下载使用。

  • bin 字段表示,以后用户在终端中可直接运行的命令。

    image-20210718161116661.png
  • files 字段表示,需要打包上传的文件。这里是 cli.js、index.js、db.js 三个文件,当然如果说,你的目录里就只有这三个 js 文件的话,那么该字段就可以直接写成: “files”: [“*.js”]

3. 发布到 NPM

  1. npm login (此步骤需要填写用户名和密码,以及邮箱。)

  2. npm publish

  3. npm logout

4. 下载使用

  1. 打开终端,全局安装:npm i -g cpc-node-todo-1

  2. 安装好后,通过 cpc-todo 来调用这个 node 应用,注意这个 cpc-todo 就是打包之前 package.json 中设置的 bin 字段的内容。

    image-20210718162342275.png
  3. 如果不想用了,通过以下命令来卸载即可: npm un -g cpc-todo-1

5. 包的更新

略。

七、单元测试

1. jest

https://www.jestjs.cn/docs/getting-started

npm install --save-dev jest

待更新…