如果你是一名现代 JavaScript 开发者,你很有可能正在使用 ESLint 和 TypeScript 的组合来辅助开发。这些工具执行的功能相似但有所不同。ESLint 是一个 linter(代码检查工具),而 TypeScript 是一个 type checker(类型检查器)。

Linter 和 type checker 是两种 静态分析 工具,它们分析代码并报告检测到的问题。虽然它们乍看之下可能很相似,但 linter 和 type checker 检测到的是不同类别的问题,并且在不同的方面发挥作用。

为了理解这些差异,首先了解什么是静态分析以及它为何有用是很有帮助的。

什么是静态分析?

静态分析是在不执行源代码的情况下检查源代码。这与 动态分析 不同,后者是在源代码执行时对其进行检查。因此,动态分析带来了执行恶意代码或在静态分析是安全执行的情况下产生副作用的内在危险,而无论源代码如何,静态分析都是安全执行的。

静态分析对于提高代码的可读性、可靠性和整体质量非常有帮助。许多开发者依赖静态分析来强制执行一致的代码格式和风格,确保代码文档完善,并捕获可能的错误。由于静态分析在源代码上运行,因此它可以在编写代码时在编辑器中建议改进。

ESLint 的静态分析被组织成一系列单独配置的 lint 规则。由于没有两条规则相互作用,因此你可以根据自己的偏好安全地打开和关闭每个规则。虽然 TypeScript 有一些单独配置的选项,但大部分分析是在类型检查功能中执行的。

ESLint 和 TypeScript 使用一些相同的分析形式来检测代码中的缺陷。它们都分析代码中作用域和变量的创建和使用方式,并且可以捕获诸如引用不存在的变量之类的问题。我们将探讨这两种工具使用代码分析信息的不同方式。

深入探讨 Linting 与类型检查

Linters 主要报告可能的缺陷,也可以用于强制执行主观意见。ESLint 和其他 linters 捕获的可能是也可能不是类型安全的问题,但它们是潜在的 bug 来源。许多开发者依靠 linters 来确保他们的代码遵循框架和语言的最佳实践。

例如,开发者有时会在 switch 语句的 case 子句末尾遗漏 breakreturn。这样做是类型安全的,并且 JavaScript 和 TypeScript 都允许。但在实践中,这几乎总是一个错误,会导致意外地运行下一个 case 子句。ESLint 的 no-fallthrough 规则可以捕获这个可能的错误:

function logFruit(value: "apple" | "banana" | "cherry") {
    switch (value) {
        case "apple":
            console.log("🍏");
            break;
        case "banana":
            console.log("🍌");
        // eslint(no-fallthrough):
        // Expected a 'break' statement before 'case'.
        // eslint(no-fallthrough):
        // 应在 'case' 前面有一个 'break' 语句。
        case "cherry":
            console.log("🍒");
            break;
    }
}
// Logs:
// 日志输出:
// 🍌
// 🍒
logFruit("banana");

另一方面,类型检查器确保值仅以值类型允许的方式使用。像 Java 这样的编译型语言在编译阶段执行类型检查。由于 JavaScript 无法指示绑定的预期类型,因此它无法自行执行类型检查。这就是 TypeScript 的用武之地。

通过允许显式的类型注解(以及对某些类型的隐式检测),TypeScript 在 JavaScript 代码之上覆盖类型信息,以执行类似于编译型语言中的类型检查。例如,TypeScript 在以下 logUppercase(9001) 调用中报告了一个类型错误,因为 logUppercase 被声明为接收 string 而不是 number

function logUppercase(text: string) {
    console.log(text.toUpperCase());
}

logUppercase(9001);
//           ~~~~
// Argument of type 'number' is not assignable to parameter of type 'string'.
// 类型“number”的参数不能赋值给类型“string”的参数。

TypeScript 侧重于报告已知的错误,而不是潜在的问题;TypeScript 报告的错误没有主观性,也无法实现项目特定的偏好。

另一种看待 ESLint 和 TypeScript 之间差异的方式是,TypeScript 强制执行你 可以 做什么,而 ESLint 强制执行你 应该 做什么。

细粒度的可扩展性

ESLint 和 TypeScript 之间的另一个区别在于配置的粒度。

ESLint 运行在一组可单独配置的 lint 规则之上。如果你不喜欢某个特定的 lint 规则,你可以针对一行代码、一组文件或整个项目关闭它。ESLint 还可以通过 插件 进行增强,插件可以添加新的 lint 规则。插件特定的 lint 规则扩展了 ESLint 配置可以从中选择的代码检查范围。

例如,以下 ESLint 配置启用了 eslint-plugin-jsx-a11y 中的推荐规则,这是一个插件,用于在使用 JSX 库(如 Solid.js 和 React)的项目中添加可访问性检查:

import js from "@eslint/js";
import jsxA11y from "eslint-plugin-jsx-a11y"

export default [
    js.configs.recommended,
    jsxA11y.flatConfigs.recommended,
    // ...
];

使用 JSX 可访问性规则的项目将会被告知其代码是否违反了常见的可访问性指南。例如,渲染一个没有描述性文本的原生 <img> 标签会收到来自 jsx-a11y/alt-text 的报告:

const MyComponent = () => <img src="source.webp" />;
//                        ~~~~~~~~~~~~~~~~~~~~~~~~~
// eslint(jsx-a11y/alt-text):
// img elements must have an alt prop, either with meaningful text, or an empty string for decorative images.
// eslint(jsx-a11y/alt-text):
// img 元素必须有一个 alt 属性,要么带有有意义的文本,要么为空字符串(对于装饰性图像)。

通过添加来自插件的规则,ESLint 配置可以根据项目构建所使用的框架的特定最佳实践和常见问题进行定制。

另一方面,TypeScript 通过项目级别的一组编译器选项进行配置。tsconfig.json 文件 允许你设置编译器选项,这些选项会更改项目中所有文件的类型检查。这些编译器选项是为 TypeScript 全局设置的,通常会更改大范围的类型检查行为。TypeScript 不允许单个项目中不同文件的编译器选项不同。

重叠区域

虽然 ESLint 和 TypeScript 的运行方式不同,并且专注于不同领域的代码缺陷,但它们之间存在一些重叠。特定类型的代码缺陷跨越了“最佳实践”和“类型安全”之间的界限,因此可以被这两种工具捕获。

我们建议在你的 TypeScript 项目中同时使用 ESLint 和 TypeScript,以确保你捕获最广泛数量和类型的缺陷。以下是一些帮助你入门的步骤:

注意: typescript-eslint 的 tseslint.configs.recommended 禁用了核心 ESLint 规则,这些规则对于 TypeScript 没有帮助。该配置保留了任何与类型检查一起使用的核心 ESLint 规则。

未使用的局部变量和参数

当我们使用 linting 时,我们建议关闭的唯一 TypeScript 编译器选项是那些启用检查未使用变量的选项:

当不使用 ESLint 时,这些编译器选项很有用。但是,它们的配置方式不像 lint 规则那样灵活,因此无法根据项目的偏好配置为更高或更低的严格级别。例如,编译器选项被硬编码为始终忽略任何名称以 _ 开头的变量,而 ESLint 的 no-unused-vars 在配置为这样做之前不会以任何不同的方式对待这些变量。

例如,以下 registerCallback 函数为其回调声明了两个参数 idmessage,但使用它的开发者只需要 message。TypeScript 的 noUnusedParameters 编译器选项不会标记未使用的参数 _

type Callback = (id: string, message: string) => void;
declare function registerCallback(callback: Callback): void;

// We only want to log message, not id
// 我们只想记录 message,而不是 id
registerCallback((_, message) => console.log(message));

JavaScript 中未使用的变量也可以被 ESLint 的 no-unused-vars 规则捕获;在 TypeScript 代码中,最好使用 @typescript-eslint/no-unused-vars 规则。lint 规则可以配置为忽略名称以 _ 开头的变量。

此外,默认情况下,lint 规则会忽略在任何已使用的参数之前的参数。一些项目倾向于永远不允许未使用的参数,无论名称或位置如何。这些更严格的偏好有助于防止 API 设计导致开发者创建许多未使用的参数。

更严格的 ESLint 配置将能够报告 _ 参数:

/* eslint @typescript-eslint/no-unused-vars: ["error", { "args": "all", "argsIgnorePattern": "" }] */
type Callback = (id: string, message: string) => void;
declare function registerCallback(callback: Callback): void;

// We only want to log message, not id
// 我们只想记录 message,而不是 id
registerCallback((_, message) => console.log(message));
//                ~
// eslint(@typescript-eslint/no-unused-vars):
// '_' is declared but never used.
// eslint(@typescript-eslint/no-unused-vars):
// '_' 已声明但从未使用过。

no-unused-vars 规则提供的额外配置级别使其可以充当与其等效的 TypeScript 编译器选项的更细粒度可配置的版本。

💡 参见 no-unused-binary-expressions:从代码审查的细枝末节到生态系统改进,了解更多 linting 和类型检查之间部分重叠的代码检查领域。

ESLint 在 TypeScript 中有用吗?

是的。

如果你正在使用 TypeScript,那么使用 ESLint 仍然非常有用。事实上,ESLint 和 TypeScript 在结合使用时功能最强大。

带有类型信息的 ESLint

传统的 ESLint 规则一次在一个文件上运行,并且不了解项目中的其他文件。它们无法根据其他文件的内容对文件做出决策。

但是,如果你的项目是使用 TypeScript 设置的,你可以选择加入“类型检查”的 lint 规则:可以提取类型信息的规则。这样做后,类型检查的 lint 规则可以根据其他文件做出决策。

例如,@typescript-eslint/no-for-in-array 能够检测到在数组类型值上使用 for...in 循环,即使这些值来自其他文件。TypeScript 不会报告对数组使用 for...in 循环的类型错误,因为这样做在技术上是类型安全的,并且可能是开发者想要做的。但是,可以配置 linter 来注意到开发者可能犯了一个错误,并且本意是使用 for...of 循环:

// declare function getArrayOfNames(): string[];
// 声明函数 getArrayOfNames(): string[];
import { getArrayOfNames } from "./my-names";

for (const name in getArrayOfNames()) {
    // eslint(@typescript-eslint/no-for-in-array):
    // For-in loops over arrays skips holes, returns indices as strings,
    // and may visit the prototype chain or other enumerable properties.
    // Use a more robust iteration method such as for-of or array.forEach instead.
    // eslint(@typescript-eslint/no-for-in-array):
    // for-in 循环遍历数组会跳过空洞,将索引作为字符串返回,
    // 并且可能会访问原型链或其他可枚举属性。
    // 使用更健壮的迭代方法,例如 for-of 或 array.forEach。
    console.log(name);
}

类型化的 linting 以降低 linting 速度(大致与类型检查速度相当)为代价,但提供了一组更强大的 lint 规则。有关使用 typescript-eslint 进行类型化 linting 的更多详细信息,请参阅 类型化 Linting:有史以来最强大的 TypeScript Linting

带有 linting 的 TypeScript

TypeScript 为 JavaScript 增加了额外的复杂性。这种复杂性通常是值得的,但任何增加的复杂性都会带来误用的可能性。ESLint 对于阻止开发者在代码中犯 TypeScript 特有的错误非常有用。

例如,TypeScript 的 {}(“空对象”)类型经常被刚接触 TypeScript 的开发者误用。它在视觉上看起来应该意味着任何 object,但实际上意味着任何非 null、非 undefined 的值——包括诸如 numberstring 之类的原始类型。@typescript-eslint/no-empty-object-type 规则可以捕获对 {} 类型的用法,这些用法可能本意是 objectunknown

export function logObjectEntries(value: {}) {
    //                                  ~~
    // eslint(@typescript-eslint/no-empty-object-type):
    // The `{}` ("empty object") type allows any non-nullish value, including literals like `0` and `""`.
    // - If that's what you want, disable this lint rule with an inline comment or configure the 'allowObjectTypes' rule option.
    // - If you want a type meaning "any object", you probably want `object` instead.
    // - If you want a type meaning "any value", you probably want `unknown` instead.
    // eslint(@typescript-eslint/no-empty-object-type):
    // `{}` (“空对象”) 类型允许任何非 nullish 值,包括像 `0` 和 `""` 这样的字面量。
    // - 如果这就是你想要的,请使用内联注释禁用此 lint 规则或配置 'allowObjectTypes' 规则选项。
    // - 如果你想要一个表示“任何对象”的类型,你可能想要使用 `object` 代替。
    // - 如果你想要一个表示“任何值”的类型,你可能想要使用 `unknown` 代替。
    console.log(Object.entries(value));
}

logObjectEntries(0); // No type error!
// 没有类型错误!

使用 ESLint 强制执行特定于语言的最佳实践有助于开发者学习和正确使用 TypeScript。

结论

像 ESLint 这样的 Linters 和像 TypeScript 这样的类型检查器对于开发者来说都是宝贵的资产。这两者捕获不同领域的代码缺陷,并且在可配置性和可扩展性方面具有不同的理念。

  • ESLint 检查代码是否符合最佳实践并保持一致性,强制执行你 应该 编写什么。
  • TypeScript 检查代码是否“类型安全”,强制执行你 可以 编写什么。

结合在一起,这两个工具可以帮助项目编写 bug 更少、一致性更高的代码。我们建议任何使用 TypeScript 的项目都额外使用 ESLint。