工具与代码质量

好工具 + 好习惯 = 高质量代码

🎯 学习目标

学完本章,你将能够:

  • 能够使用 npm 命令管理项目依赖(install、uninstall、update、audit)
  • 能够理解 package.json 的结构与语义化版本规则(^、~、*)
  • 能够配置和使用 ESLint 进行代码风格检查与自动修复
  • 能够配置 Prettier 实现代码自动格式化并与 ESLint 协同工作
  • 能够使用 Vitest 编写基础单元测试并理解测试金字塔概念

你有没有想过

为什么大公司的代码风格统一?为什么改了一处另一处不会偷偷坏掉?为什么有人敢放心重构几千行代码?

答案不是"他们技术好",而是他们用了一套工具链:包管理器管依赖、ESLint 检查代码风格、Prettier 自动格式化、单元测试保证逻辑正确。

这些工具不是"锦上添花",而是专业开发的基础设施。这一章你将学会搭建这套工具链。

Part 1: npm 包管理

学会用它,才能用好别人写的轮子

项目里要装一个 Express、一个测试框架、一个代码检查工具——这些从哪来?全靠 npm 下载和管理。npm 是 Node.js 世界的"应用商店",装包、卸包、锁版本全靠它。接下来你会掌握 npm 的常用命令、package.json 的结构,以及版本号那个奇怪的 ^ 到底在干什么。

npm 常用命令

init、install、run——这三个能覆盖 90% 的日常操作

$ npm init -y                # 初始化 package.json
$ npm install express        # 安装生产依赖
$ npm install -D vitest      # 安装开发依赖
$ npm uninstall package      # 卸载
$ npm update                 # 更新所有依赖
$ npm outdated               # 检查过时的包
$ npm audit                  # 安全审计
$ npm run build              # 运行 scripts 中的命令

dependencies 是运行时需要的包(如 express),devDependencies 是开发时才需要的包(如测试框架)。生产部署时可以用 npm install --production 跳过开发依赖。

package.json

项目的身份证:名字、版本、依赖、脚本都在这里

{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest"
  },
  "dependencies": {
    "express": "^4.18.0"
  },
  "devDependencies": {
    "vitest": "^1.0.0"
  },
  "engines": {
    "node": ">=18"
  }
}

语义化版本

^ 和 ~ 控制更新范围

写法含义示例
^1.2.3兼容小版本更新允许 1.x.x,不允许 2.0.0
~1.2.3仅补丁更新允许 1.2.x,不允许 1.3.0
1.2.3精确锁定只用 1.2.3

版本号格式:主版本.次版本.补丁(major.minor.patch)。主版本变意味着有破坏性改动。

npm vs yarn vs pnpm:pnpm 速度快、磁盘占用少、有严格的依赖隔离。新项目推荐 pnpm。

Part 2: ESLint 与 Prettier

两者配合,代码风格全团队一致

依赖装好了,代码能跑了,但新问题来了:张三用双引号、李四用单引号、王五缩进 2 格、你缩进 4 格。代码评审时光为格式吵半天。ESLint 负责找逻辑问题,Prettier 负责统一排版格式。这一节我们把这两个工具配上,让代码风格再也不是争论的焦点。

ESLint 配置

定义规则,告诉团队什么写法可以、什么不行

$ npm install -D eslint @eslint/js
$ npx eslint --init
// eslint.config.js(Flat Config,ESLint 9+)
import js from '@eslint/js';
export default [
    js.configs.recommended,
    {
        rules: {
            'no-unused-vars': 'warn',   // 未使用变量警告
            'no-console': 'warn',       // console 警告
            'eqeqeq': 'error',          // 强制 ===
            'curly': 'error',           // 强制花括号
            'prefer-const': 'error',    // 优先用 const
            'no-var': 'error',          // 禁止 var
        }
    }
];

ESLint 使用

检查和自动修复,一条命令搞定

$ npx eslint src/            # 检查所有文件
$ npx eslint src/ --fix      # 自动修复能修的问题

规则等级三种:off 关闭、warn 警告、error 报错。CI 中建议全设为 error,有警告就不通过。

不要忽略 ESLint 的警告。 警告现在是"还能跑",将来升级依赖可能变成"跑不了"。养成零警告的习惯。

Prettier 格式化

不需要争论缩进是 2 还是 4——Prettier 统一决定

$ npm install -D prettier
// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 4,
  "trailingComma": "es5",
  "printWidth": 100
}
$ npx prettier --write "src/**/*.js"   # 格式化
$ npx prettier --check "src/**/*.js"   # 只检查不修改

ESLint + Prettier

一个管逻辑,一个管格式,互不冲突

$ npm install -D eslint-config-prettier eslint-plugin-prettier

ESLint 和 Prettier 有些规则会冲突(比如分号、引号)。eslint-config-prettier 会关闭 ESLint 中所有和 Prettier 冲突的规则。

推荐工作流:保存时 Prettier 自动格式化 + ESLint 实时检查。在 VS Code 中安装对应扩展即可。

Part 3: JSDoc 类型注释

不用 TypeScript,也能获得类型提示和文档

ESLint 和 Prettier 搞定了代码风格,但还有一个痛点:调用函数时不知道该传什么参数,IDE 也没法提示。暂时不想上 TypeScript?JSDoc 是一个轻量级的替代方案。用注释给函数签名,编辑器就能帮你检查参数类型、生成文档。这一节看看它能做什么,又有哪些局限性。

JSDoc 注释

用注释给函数签名,编辑器就能提示类型

/**
 * 计算两个数的和
 * @param {number} a - 第一个数
 * @param {number} b - 第二个数
 * @returns {number} 两数之和
 */
function add(a, b) { return a + b; }

// 自定义类型
/**
 * @typedef {Object} User
 * @property {string} name - 用户名
 * @property {number} age - 年龄
 * @property {string} [email] - 可选邮箱
 */

/**
 * @param {User} user
 * @returns {Promise<boolean>}
 */
async function saveUser(user) { /* ... */ }

VS Code 原生支持 JSDoc。写了注释后,调用函数时会自动显示参数类型和说明。

JSDoc 进阶

泛型、回调、数组类型

// 数组类型
/** @type {string[]} */
let names = [];

// 泛型
/**
 * @template T
 * @param {T[]} arr
 * @returns {T|undefined}
 */
function first(arr) { return arr[0]; }

// 回调类型
/**
 * @callback FilterFn
 * @param {string} item
 * @param {number} index
 * @returns {boolean}
 */

/** @param {FilterFn} fn */
function filter(fn) { /* ... */ }

Part 4: 单元测试

代码质量的"安全网"——改了代码跑一下测试,马上知道有没有搞坏什么

工具链的最后一块拼图:怎么确保改了一处代码,不会悄悄搞坏另一处?答案是写测试。单元测试就像给每个函数配了一个"质检员",输入什么、期望输出什么,全都写清楚。这一节用 Vitest 写几个真实的测试用例,体会"先写测试、再改代码"的安全感。

测试基础概念

describe 分组、it 写用例、expect 断言

import { describe, it, expect, beforeEach } from 'vitest';
import { add, multiply } from './math.js';

describe('Math utilities', () => {
    beforeEach(() => {
        // 每个测试前执行(如重置状态)
    });

    it('should add two numbers', () => {
        expect(add(2, 3)).toBe(5);
        expect(add(-1, 1)).toBe(0);
    });

    it('should multiply', () => {
        expect(multiply(2, 3)).toBe(6);
    });

    it('should handle edge cases', () => {
        expect(add(0, 0)).toBe(0);
        expect(multiply(0, 5)).toBe(0);
    });
});

常用断言

expect 的各种匹配方式

断言用途
expect(value).toBe(expected)严格相等(===)
expect(value).toEqual(expected)深度相等(对象/数组)
expect(value).toBeTruthy()真值判断
expect(value).toBeFalsy()假值判断
expect(fn).toThrow()函数是否抛错
expect(array).toContain(item)数组是否包含
expect(fn).toHaveBeenCalled()mock 函数是否被调用

Vitest 和 Jest 的 API 几乎完全一样。Vitest 更快,且原生支持 ESM。

常见问题

node_modules 不要提交到 Git。 在 .gitignore 中加上 node_modules/。别人 clone 后 npm install 就能恢复依赖。

问题一:npm install 报错 ERESOLVE

原因:依赖版本冲突,两个包要求同一个依赖的不同版本。

修复:尝试 npm install --legacy-peer-deps,或者检查 package.json 中的版本范围。

问题二:ESLint 和 Prettier 规则冲突

原因:ESLint 的格式规则和 Prettier 的配置不一致。

修复:安装 eslint-config-prettier,它会关闭 ESLint 中所有和 Prettier 冲突的规则。

问题三:测试通过了但线上还是出 bug

原因:测试只覆盖了"正常路径",没有测试边界情况和错误路径。

修复:测试中专门写错误输入、空值、超大值等边界用例。

Git Hooks

提交前自动检查——不合规代码根本进不了仓库

$ npm install -D husky lint-staged
$ npx husky init
$ echo "npx lint-staged" > .husky/pre-commit
// package.json
{
  "lint-staged": {
    "*.js": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"]
  }
}

Husky 在 git commit 前自动触发 lint-staged,只检查暂存区文件,速度快。如果 ESLint 或 Prettier 报错,提交会被拦截。

用 commitlint 规范提交信息

$ npm install -D @commitlint/cli @commitlint/config-conventional
$ echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
$ echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

提交信息必须符合 type(scope): message 格式,例如 feat(auth): 添加登录功能。type 只允许 feat / fix / docs / refactor 等约定值。

husky init 会在 .husky/ 下创建钩子脚本。 确保 .husky/ 目录被提交到 Git,否则队友 clone 后钩子不生效。

代码覆盖率

量化测试的"覆盖面"——哪些代码被测到了,哪些没有

维度含义
行覆盖率被执行到的代码行占比
分支覆盖率if/else 分支被执行到的占比
函数覆盖率被调用到的函数占比
# 运行覆盖率报告
$ npx vitest --coverage
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
    test: {
        coverage: {
            provider: 'v8',          // 或 'istanbul'
            reporter: ['text', 'html'], // 终端输出 + HTML 报告
            include: ['src/**/*.js'],
            thresholds: {
                lines: 80,           // 行覆盖率 ≥ 80%
                branches: 70,        // 分支覆盖率 ≥ 70%
                functions: 80,
            }
        }
    }
});

设置 thresholds 后,覆盖率不达标测试会直接失败,防止覆盖率悄悄下降。

100% 覆盖率 ≠ 没有缺陷。 覆盖率只衡量"代码有没有被执行到",不衡量"断言是否充分"。一段 if/else 都跑到了,但断言只写了 expect(true).toBe(true),覆盖率是 100%,但什么都没测。

试一试

练习一

任务:初始化一个项目,安装 ESLint,配置规则禁止 var 和要求使用 ===。

$ mkdir my-project && cd my-project
$ npm init -y
$ npm install -D eslint @eslint/js

参考答案

// eslint.config.js
import js from '@eslint/js';
export default [
    js.configs.recommended,
    {
        rules: {
            'no-var': 'error',
            'eqeqeq': 'error',
        }
    }
];

练习二

任务:给下面这个函数写单元测试,覆盖正常情况和边界情况。

function clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
}

参考答案

import { describe, it, expect } from 'vitest';

describe('clamp', () => {
    it('值在范围内不变', () => {
        expect(clamp(5, 0, 10)).toBe(5);
    });
    it('小于最小值取最小值', () => {
        expect(clamp(-3, 0, 10)).toBe(0);
    });
    it('大于最大值取最大值', () => {
        expect(clamp(15, 0, 10)).toBe(10);
    });
    it('边界值', () => {
        expect(clamp(0, 0, 10)).toBe(0);
        expect(clamp(10, 0, 10)).toBe(10);
    });
});

练习三

任务:为下面的 TodoList 类编写完整的单元测试,覆盖 add(item)、remove(id)、toggle(id)、filter(completed) 四个方法。要求覆盖正常路径和边界情况(如添加空字符串、删除不存在的 id)。

// todo.js
let nextId = 1;
export function createTodoList() {
    const items = [];
    return {
        add(item) {
            if (!item || typeof item !== 'string') throw new Error('无效的待办项');
            const todo = { id: nextId++, text: item, completed: false };
            items.push(todo);
            return todo;
        },
        remove(id) {
            const idx = items.findIndex(t => t.id === id);
            if (idx === -1) return null;
            return items.splice(idx, 1)[0];
        },
        toggle(id) {
            const todo = items.find(t => t.id === id);
            if (!todo) return null;
            todo.completed = !todo.completed;
            return todo;
        },
        filter(completed) {
            return items.filter(t => t.completed === completed);
        }
    };
}

参考答案

import { describe, it, expect, beforeEach } from 'vitest';
import { createTodoList } from './todo.js';

describe('TodoList', () => {
    let list;
    beforeEach(() => { list = createTodoList(); });

    describe('add', () => {
        it('正常添加待办项', () => {
            const todo = list.add('买牛奶');
            expect(todo).toEqual({ id: 1, text: '买牛奶', completed: false });
        });

        it('添加空字符串应抛错', () => {
            expect(() => list.add('')).toThrow('无效的待办项');
        });

        it('添加非字符串应抛错', () => {
            expect(() => list.add(null)).toThrow('无效的待办项');
            expect(() => list.add(123)).toThrow('无效的待办项');
        });
    });

    describe('remove', () => {
        it('正常删除', () => {
            list.add('任务A');
            const removed = list.remove(1);
            expect(removed.text).toBe('任务A');
            expect(list.filter(true).length + list.filter(false).length).toBe(0);
        });

        it('删除不存在的 id 返回 null', () => {
            expect(list.remove(999)).toBe(null);
        });
    });

    describe('toggle', () => {
        it('切换完成状态', () => {
            list.add('任务A');
            const toggled = list.toggle(1);
            expect(toggled.completed).toBe(true);
            list.toggle(1);
            expect(toggled.completed).toBe(false);
        });

        it('切换不存在的 id 返回 null', () => {
            expect(list.toggle(999)).toBe(null);
        });
    });

    describe('filter', () => {
        it('按完成状态过滤', () => {
            list.add('任务A');
            list.add('任务B');
            list.toggle(1);
            expect(list.filter(true)).toHaveLength(1);
            expect(list.filter(false)).toHaveLength(1);
        });

        it('空列表过滤返回空数组', () => {
            expect(list.filter(false)).toEqual([]);
        });
    });
});

📝 要点总结

本章核心知识点

npm 包管理

init/install/run 核心命令,dependencies vs devDependencies

语义化版本

MAJOR.MINOR.PATCH,^ 允许小版本,~ 允许补丁版本

ESLint

静态代码分析,可修复规则,配置文件 .eslintrc

Prettier

代码格式化工具,与 ESLint 分工明确

测试与 Git Hooks

Vitest 单元测试,husky + lint-staged 提交前检查

下一章

工具链已经就绪。下一章学习设计模式初探——用经典模式解决代码中的常见问题。

1 / X