查看原文
其他

【第1639期】如何使用 JSDoc 保证你的 Javascript 类型安全性

赵丽云 前端早读课 2019-06-14

前言

今日早读文章由汽车之家@赵丽云翻译授权分享。

正文从这开始~~

通常,开发人员认为,如果想要保证 JavaScript 的类型安全,需要使用 TypeScript 或 Flow。而在本文中,我们将探讨另一种类型检查方式,即:在 VisualStudioCode(https://code.visualstudio.com/) 编辑器中使用 JSDoc (http://usejsdoc.org/) 注释。从技术上讲,就是使用 JSDoc 注释和类型推断,在代码编写的过程中检查 JavaScript 代码的类型。

类型检查 (Types)

类型为我们提供了一系列有关代码性质的有价值信息,还能识别拼写错误,方便重构等。目前,大多数编辑器都能根据从代码中收集到的类型信息,提供某种类型的智能感知(IntelliSense),不过我个人最喜欢的还是 Visual Studio Code。添加了适当的类型信息后,当鼠标悬停在相应位置时,会给出一些提示,如符号定义,代码完成,跨文件的符号重命名等。

类型检查的优点

1、及早发现类型错误
2、代码更易于分析
3、IDE 支持
4、有助于代码重构
5、提高代码可读性
6、编码时提供有价值的智能感知

这些好处都是 使用 TypeScript、Flow 甚至是 JSDoc 注释获得的。

获得正确的类型不是一件易事

熟练掌握类型需要时间和经验。如果你有用过严格类型语言的背景,如 Java,C# 等,类型转换将容易些。JavaScript 的类型系统没有强类型语言提供的类型系统复杂。如果你习惯使用动态类型的语言(如 Python,Ruby 或 JavaScript),那么为 JavaScript 提供类型会感觉是一件很麻烦的事情。如果你之前从未使用过类型检查,先有一些心理准备吧。还记得第一次使用 jslint 或 jshint 的时候吗?加上了 JSDoc 只会更加严格。

对 JavaScript 进行类型检查有时是比较令人发怵的。

主动(添加类型检查)

如果你发现自己在确保类型正确这件事上,花费了太多时间,那么你可能需要思考换一种类型检查的方式了。使用 JSDoc 注释是为 JavaScript 提供类型信息最简单的方法。

如果你不太熟悉对 JavaScript 使用类型检查,最好先了解下类型检查的工作原理。这有一篇关于 Flow 是如何进行类型检查 的好文,地址:https://flow.org/en/docs/lang/types-and-expressions/。

不过,不要觉得在编写过程中使用了类型检查,就万无一失了。通过在适当的位置使用类型保护 (type guards),只能说是避免了代码运行时必须处理的一部分错误。

查看 bug 报告

JSDoc 提供类型信息的注释

JSDoc 是一个根据 Javascript 文件中注释信息,生成 JavaScript 应用程序或库、模块的 API 文档 的工具。JSDoc 在 JavaScript 中提供类型信息作为注释,因此从这一点上讲,相比 TypeScript,它更像 Flow。不同之处在于 JSDoc 注释是标准的 JavaScript 注释,使用起来非常方便。因此,不需要构建步骤来转换为 JavaScript 或像 Flow 那样借助插件来删除注释。当你压缩代码时,JSDoc 注释会自动被删除。

事实上,TypeScript 之所以能通过 d.ts 文件提供出色的智能感知体验,其实是因为其包含了 JSDoc 注释。

设置 Visual Studio Code 编辑器

要在 Visual Studio Code 中充分利用 JSDoc,需要进行一些设置。在 Visual Studio Code 中使用快捷键 Ctrl + Shift + P 打开 setting.json 设置文件:

然后将下面的设置信息添加到配置文件中:

"javascript.implicitProjectConfig.checkJs": true

编写 JavaScript 时,这将为你提供一些基本的智能感知,在类型错误下面会有红色波浪线标记:

红色波浪线表示有类型错误

将鼠标悬停在已标记的类型错误上将弹出对该错误的解释:

有关类型错误的消息提示。

使用 JSDoc 注释来描述代码的类型能辅助解决这些类型错误的问题。

打开和关闭类型检查的替代方法

你还可以跳过上面设置配置文件的那一步,直接在文件顶部添加 // @ts-check,这相当于告诉 VS Code 检查某一单个文件。

// @ts-check
// The TypeScript engine will check all JavaScript in this file.
import { h, render, Component } from 'preact'

function Title(props) {
return (
<h1>{props.message}</h1>
)
}

如果你的编辑器已经默认设置为检查 JavaScript,那么可以通过在文件顶部添加 // @ts-nocheck 来选择不检查某个文件。

// @ts-nocheck
// The TypeScript engine will not check this file.
import { h, render, Component } from 'preact'

function Title(props) {
return (
<h1>{props.message}</h1>
)
}

如果是一行代码的类型或者从该行开始的代码块在处理上遇到了麻烦,可以在该行的上方添加 // @ts-ignore,这样便能绕过检查。

import { h, render, Component } from 'preact'

// Tell TypeScript to ignore the following block of code:
// @ts-ignore
function Title(props) {
return (
<h1>{props.message}</h1>
)
}

如果你的项目有 jsconfig.json 文件,可以在 compilerOptions 下添加 checkJS 以打开 JavaScript 的 TypeScript 检查,如下:

{
"compilerOptions": {
"checkJs": true
},
"exclude": [
"node_modules",
"**/node_modules/*"
]
}

JSDoc 注释

如果你不熟悉 JSDoc,可以从以下链接中了解更多信息:

1、JSDoc:主站点 (http://usejsdoc.org/)
JSDoc 中文文档参考地址: (https://www.html.cn/doc/jsdoc/index.html)
2、JSDoc:入门 (http://usejsdoc.org/about-getting-started.html)
3、JSDoc:ES6 Classes (http://usejsdoc.org/howto-es2015-classes.html)
4、Visual Studio Code:JSDoc 语法支持 (https://github.com/Microsoft/TypeScript/wiki/JavaScript-Language-Service-in-Visual-Studio#JSX)
5、Visual Studio Code:基于 JSDoc 的智能感知 (https://github.com/Microsoft/TypeScript/wiki/JavaScript-Language-Service-in-Visual-Studio#JsDoc)
6、Visual Studio Code:JSDoc 支持的功能(荐)(https://github.com/Microsoft/TypeScript/wiki/JsDoc-support-in-JavaScript)
7、JavaScript 智能感知 (https://docs.microsoft.com/zh-cn/visualstudio/ide/javascript-intellisense?view=vs-2019)

使用 JSDoc 定义类型

以下简要介绍下 JSDoc 提供的类型功能

指明类型:

使用 @type 指明一个类型:

/**
* @type {string} name A name to use.
*/

const name = 'Joe'

类型的可选值有 number, string, undefined, Array, Object。就数组而言,你还可以通过 array 后附加 [] 指示包含在数组中的类型,比如 string[] 表示字符串数组,还可以选择 any[]、 number[],或 Object[]。

你还可以定义联合类型和交集类型:

// Union type:
/**
* @type {number | string} value The value of the product.
*/

const price = 12 // or '12'

// Intersection type:
/**
* @type {{name: string}, {age: number}}
*/

const person = {
name
: 'Joe',
age
: 32
}
默认类型

JSDoc 提供了以下类型:

1、null
2、undefined
3、boolean
4、number
5、string
6、Array 或 []
7、Object 或 {}

你可以设置一个类型化的数组: any[], number[], string[],还可以设置一组对象类型: Employee[]。

自定义类型

使用 @typedef 定义自定义类型。这类似于 TypeScript 的 interface。定义自定义类型后,你可以为其定义属性。 @typedef 标签在描述自定义类型时是很有用的,特别是如果你要反复引用它们的时候。这些类型可以在其它标签内使用,如 @type 和 @param。

属性

使用 @property 定义对象的成员。

/**
* A person object with a name and age.
* @typedef {Object<string, any>} Person
* @property {string} name The name of the person.
* @property {number} age The age of the person.
*/

/**
* @type {Person} person
*/

const person = {
name
: 'Joe',
age
: 32
}

将鼠标悬停在对象上将会提供以下类型信息:

person 对象的智能感知

将鼠标悬停在 name 和 age 属性上可以获得以下信息:

name 属性的智能感知
age 属性的智能感知

恰当的 JSDoc 类型注释可以向 TypeScript 引擎提供有关代码的精确信息,从而产生高级智能感知,它不仅仅是类型的提示,它是描述对象及其属性的信息。

函数

JSDoc 中,使用 @function 或 @method 定义函数或 class 方法,不过通常用不到这两个类型。对于函数的话,指明它的参数和返回类型就够了。在 VS Code 里面,JavaScript 已经能够判断代码块是函数或是 class 方法。但是,如果对象的一个成员是函数,或者类构造函数里面有函数,那么可以将这些成员的类型定义为函数类型,下面有两个例子:

/**
* 一个 person 对象,其中包含了 name, age 和 sayName 方法.
* @typedef {Object} Person
* @property {string} name The person's name.
* @property {number} age The person's age.
* @property {Function} sayName A function that alerts the person's name.
*/

const person = {
name
: 'Joe',
age
: 32,
sayName
() {
alert
(this.name)
}
}

下面是一个带有函数属性的类构造函数:

/**
* Class to create a person object.
*/

class Person {
constructor
(props) {
/**
* @property {string} name The person's name.
*/

this.name = props.name
/**
* @property {number} age The person's name.
*/

this.age = props.age
/**
* @property {Function} sayName A method to annouce the person's name.
* @returns void
*/

this.sayName = () => alert(this.name)
}
}

const guy = new Person({
name
: Sam,
age
: 32
})
guy
.sayName()

在上面的代码块中,当我们将鼠标悬停在 guy.sayName 上时,会出现有关该属性的智能感知,如下:

可选参数

使用 [] 表示可选。尽管 JSDocs 有多种表示 可选性 的方法,但 [] 是唯一能够在 VS Code 和 TypeScript 中表示可选的方法。可以看到,下面代码中的 age 属性被标记为可选,说明我们实际创建的对象可以没有这个属性。

/**
* @typedef {Object} Options The Options to use in the function createUser.
* @property {string} firstName The user's first name.
* @property {string} lastName The user's last name.
* @property {number} [age] The user's age.
*/

/**
* @type {Options} opts
*/

const opts = { firstName: 'Joe', lastName: 'Bodoni', age: 32 }

注意,在之后使用 opts 的过程中,都会有提示,可以使用上下箭头滚动查看 opt 的属性,同时还能看到属性的类型。如下图,现在高亮的是 age 属性,为 number 类型,后面带个问号表明它是可选的。

注:age 后面的 问号(?) 表明该属性是可选的

可扩展对象的动态属性 (Expando properties)

地址:https://developer.mozilla.org/zh-CN/docs/Glossary/Expando

对于向对象中动态添加属性这种情况来说,类型检查存在一个问题,如下:

由于这种新的语言类型判断服务是由静态分析而非执行引擎提供的支持(可阅读此问题(https://github.com/Microsoft/TypeScript/issues/4789) 以获取差异信息),因此有一些 JavaScript 模式无法再被检测到。最常见的就是 “expando”。目前,无法为对象声明后再添加的属性提供智能感知。

如果要处理可扩展对象的动态属性,唯一的方法是使用中括号和引号:

// Add custom property to node of type Element:
const btn = document.createElement('button')
btn
.nodeValue = 'A Button'
// Will generate error that property does not exist on type Node:
btn
.isButton = true
// Escape expando property:
btn
['isButton'] = true
定义对象类型

在 JSDoc 的类型中, Object 和 object 被视为 any 。我知道,这可能听起来不合逻辑。在广泛查看了 JSDoc 的使用场景之后,TypeScript 团队得出了 这个结论(https://github.com/Microsoft/TypeScript/issues/18396)。当你想要创建一个Object 类型,有两种办法:使用对象字面量或者使用带有类型的 Object:

// This gets resolved to type `any`.
/**
* As Object.
* @type {Object} obj1
*/


// This designates a empty object literal.
// Adding properties will generate a type error.
/**
* As object literal:
* @type {{}} obj2
*/


// Define properties in object literal
/**
* Object literal with properties:
* @type {{name: string, age: number, job: string}} employee
*/


// Define generic object.
// This can have any number of properties of type any.
/**
* @type {Object<string, any>} person
*/

在定义对象的四种方法中,前两种方法最不实用。它们将具有我们之前讨论过的与对象可扩展属性相同的问题。第三种方法适用于简单的对象。但第四种方法最灵活,允许给对象赋值多种不同类型的属性,并且属性定义注释地越详细,显示的智能感知也越丰富:

// Define generic object.
// This can have any number of properties of type any.
/**
* @type {Object<string, any>} Member
* @property {string} name The members's name.
* @property {number} [age] The members's age.
* @property {string} [job] The member's job.
*/

/**
* @type {Member} Jack
*/

const Jack = {
age
: 28
}

如下图,加上一些类型定义后,可以看到我们能从 Jack 这个对象上得到的智能感知:

下面的写法更简单些,虽然也能检测类型,但相对来说提供的智能感知功能就少了:

// Define generic object.
// This can have any number of properties of type any.
/**
* @type {Object<string, any>}
*/

const Jack = {
age
: 28
}

虽然这也是一种正确的定义类型的写法,但最终显示的智能感知太少了

注释的复杂与简单

如何定义类型?当然是越详细越好,如前面有关对象类型的示例所示,详细的类型定义能显示更多的智能感知。但是如果代码永远不会被其他人使用,那么简单的注释也就足够了。不过有一点请记住,如果使用了更简单的注释,然后在几个月后再想要整理代码,操作起来会很难。

泛型

使用 @template 标记定义泛型:

// Generic types may also be used
/**
* @template T
* @param {T} param - A generic parameter that flows through to the return type
* @return {T}
*/

function genericFnc(param) {
return param
}
类型转换(Type Casting)

有时,为了解决静态类型检查器无法识别的类型,需要进行类型转换。在 {} 中包含要转换的类型。格式为:

/** @type{some type here} * /
(要转换的元素)

通常,在处理 DOM 节点时必须进行类型转换。原因很简单,TypeScript 是一种静态类型检查器,在编码和构建期间进行检查。相反,当代码在浏览器中加载时,浏览器可以执行自动类型强制,在节点和元素之前进行转换。你可以获取节点并在其上调用 Element 方法。浏览器将为你强制处理。然而,类型检查会觉得你将一种类型视为了另一种类型,会将其标记为错误。类型转换允许类型检查器明白哪些地方进行了强制类型转换。

下面是一个类型转换的示例:

// 创建一个 button 元素.

// 类型是一个 Node 节点.

const btn = document.createElement('button')

// 由于 btn 的类型是 Node,所以我们无法调用 setAttribute.

// 将 btn 的类型从 Node 转换为 Element 可破.

/** @type {Element} */

(btn).setAttribut('disabled', true)
引入类型

通过从类型最初定义的位置引入自定义类型,可以实现在文件中重复使用自定义类型。假设我们有一个文件,其中有一个创建虚拟节点的函数。在该文件中,我们使用 JSDoc 注释定义的虚拟节点类型,然后在其他文件中引入该函数以创建虚拟节点。如下:

/**
* Props define attributes on a virtual node.
* @typedef {Object.<string, any> | {}} Props
* @property {Children} Props.children
*/

/**
* The vnode children of a virtual node.
* @typedef {VNode[]} Children
*/

/**
* Define a custom type for virtual nodes:
* @typedef {string | number | Function} Type
* @typedef {Object.<string, any>} VNode
* @property {Type} VNode.type
* @property {Props} VNode.props
* @property {Children} VNode.children
* @property {Key} [VNode.key]
*/

假设我们在另一个文件中引入了 createVirtualNode 函数,为了显示类型信息,可以从另一个文件(自定义类型定义的位置)引入类型,如下所示:

import { createVNode } from '../vnode'
/**
* @typedef {import('./vnode').VNode} VNode
*/


// Some code that create options for createVNode to use.
const options = ...
/**
* @param {Object<string, any>} options
* @return {VNode} VNode
*/

const vnode = createVNode(options)

注意上面是如何从 vnode.js 引入自定义类型的,我们也可以使用相同的方式来定义函数的返回值。

尽量避免定义 any 类型

虽然定义 any 类型能够摆脱代码被红色波浪线缠身的问题,但使用联合类型或交集类型会更好:

// Union type:
/**
* @type {number | string} value The value of the product.
*/

const price = 12 // or '12'

// Intersection type:
/**
* @type {{name: string}, {age: number}}
*/

const person = {
name
: 'Joe',
age
: 32
}

如果已经确定肯定会有类型转换等情况,那么再使用 any 类型定义。也可以使用 *,二者含义相同。

在构建期间检查类型

使用 JSDoc 注释写好类型信息后,也可以使用 NPM 脚本实现在代码构建时进行类型检查:

"scripts": {
"checkjs": "tsc --allowJs --checkJs --noEmit --target ES5 src/*.js"
}

加了上面的代码后,运行 npm run checkjs 就可以验证类型。任何类型错误都将连同代码行号一起被记录在控制台中:

上图表示文件 lib / vdom.js 中的第 250 行有类型错误

我们可以通过类型转换来修正这个错误,让类型检查器知道 newVNode.type 通常是一个数字,但在这里应该被视为一个字符串:

element.nodeValue = /** @type {string} */ (newVNode.type)

除类型外,JSDoc 注释还提供了更多信息

TypeScript 用户经常抱怨 JSDoc 注释比 TypeScript 类型更冗长。确实如此,但 JSDoc 注释提供了更多信息,不仅仅只有类型,例如类型的描述、函数的作用等,同时提供了更加丰富的智能感知。

坦率地说,TypeScript 和 Flow 的类型系统比 JSDoc 所包含的要复杂得多。但是,JavaScript 本身就是一种松散类型的语言,没有必要试图强制让 JavaScript 遵循 TypeScript 和 Flow 语义的严格性。

下面是 Microsoft 如何在其 TypeScript 文件中使用 JSDoc 注释定义类型以获得更丰富的智能感知的例子,这是 lib.es5.d.ts 文件中的一段代码:

/**
* Converts A string to an integer.
* @param s A string to convert into a number.
* @param radix A value between 2 and 36 that specifies the base of the number in numString.
* If this argument is not supplied, strings with a prefix of '0x' are considered hexadecimal.
* All other strings are considered decimal.
*/

declare
function parseInt(s: string, radix?: number): number;

/**
* Converts a string to a floating-point number.
* @param string A string that contains a floating-point number.
*/

declare
function parseFloat(string: string): number;

/**
* Returns a Boolean value that indicates whether a value is the reserved value NaN (not a number).
* @param number A numeric value.
*/

declare
function isNaN(number: number): boolean;

/**
* Determines whether a supplied number is finite.
* @param number Any numeric value.
*/

declare
function isFinite(number: number): boolean;

/**
* Gets the unencoded version of an encoded Uniform Resource Identifier (URI).
* @param encodedURI A value representing an encoded URI.
*/

declare
function decodeURI(encodedURI: string): string;

在运行时的类型安全性

TypeScript,Flow 和 JSDoc 都不能确保在运行时不会出现类型错误。

类型检查仅在代码编写或构建时起作用。运行期间发生的事情是另外一回事。浏览器可能会存在影响代码的错误,你正在使用的库和框架也可能有未知的错误。 SmokeytheBear(https://en.wikipedia.org/wiki/Smokey_Bear) 有一句谚语:“只有你才能预防森林火灾”。作为一名开发人员,只有你能确保在关键时刻没有类型错误。当你使用不同来源的第三方库和数据时,在运行期间防止类型错误尤其困难。保护好自己代码的类型安全。

只要编写了一个有参数类型定义的函数,就需要在使用之前进行类型检查。

/**
* Set the title with the provided value.
* @param {string} newTitle
* @return {Node} H1 element.
*/

function setTitle(newTitle) {
return (
<h1>{newTitle}</h1>
)
}

就上面函数的参数而言,如果值不是字符串,将导致意外的渲染结果。

上面的函数看起来很好,但如果传递的 newTitle 是对象或数组会发生什么呢?我们可以重构这个函数,检查下传入参数的类型:

/**
* Set the title with the provided value.
* @param {string} newTitle
* @return {Node} H1 element.
*/

function setTitle(newTitle) {
if(typeof newTitle === 'string') {
return (
<h1>{newTitle}</h1>
)
} else {
// Handle wrong type:
console
.error(`The wrong type was provided: ${typeof newtitle}`)
}
}

首先检查类型,如果类型错误,就把问题打印出来。

使用了条件判断,可以根据类型的不同执行不同的操作。类型保护是确保 JavaScript 中类型安全的唯一方法。使用类型保护还可以输出包含信息的日志和错误消息,从而帮助确定问题所在。

小结

JSDoc 注释为 JavaScript 中的类型验证提供了引人注目的解决方案

1、通过在开启了 JavaScript 类型检查的 VS Code 编辑器中使用 JSDoc 注释代码,能够获得静态类型检查提供的良好开发体验,没有单独的构建步骤,但却提供了静态类型的大多数功能。在 VS Code 中,JSDoc 注释将帮助显示符号定义,跨文件的符号重命名,拼写错误,未使用的变量和不正确的类型等。

2、无论是使用 TypeScript 还是 JSDoc 来处理类型,处理 DOM 时都会麻烦一点。比如对于可扩展属性需要使用中括号和引号: [‘属性名’]。

3、如果你是一个编写 JavaScript 的大型团队成员,通过在 VS Code 中开启类型检查和 JSDoc 注释,可以确保生成的代码具有正确的类型,并能提供强大的智能感知体验,有助于理解,可以帮助新的开发人员更快地加入进来。

4、真正的类型安全意味着在运行时要检查类型。在适当的地方使用类型保护。

5、需要提前考虑如何处理错误类型发生的情况。可以选择记录打印错误,也可以提醒用户,或者使用判断逻辑分情况处理,亦或者忽略等。

6、JSDoc 注释为你的代码提供有价值的类型信息。它们只是标准的 JavaScript 注释。这意味着你的代码是可以运行的有效 JavaScript。在压缩代码时,无需执行任何特殊操作,代码中的 JSDoc 注释将会自动被删除。

7、如果你有过使用 Java 或 C# 的背景,没错,TypeScript 可能是更好的选择,因为它更接近于你的习惯。

8、TypeScript,Flow 或 JSDoc 所标记的类型问题在浏览器中运行时不一定会产生错误。比如:虽然 string 和 number 之间的差异对类型检查很重要,但浏览器可以根据需要很容易地强制执行类型转换。但是,这也可能会产生错误,最好是显示地将值转换成你需要的类型。

9、TypeScript 有一些 JavaScript 中没有的语法功能,如:, interface, public, private implements。这让 JavaScript 感觉更像 Java 或 C#。TypeScript 或 Flow 都不能直接在浏览器中使用,但带有 JSDoc 注释的 JavaScript 可以在浏览器中运行。JSDoc 实际上是用于注释 JavaScript 的标准方法。

关于本文
译者:@赵丽云
译文:https://mp.weixin.qq.com/s/PcHu-DeZDQCdtWjzb-QkBA
作者:@TruckJS
原文:https://medium.com/@trukrs/type-safe-javascript-with-jsdoc-7a2a63209b76

为你推荐


【第897期】用Flow编写更好的JavaScript代码


【第1612期】TypeScript —— 面向编辑器编程


【第1123期】注释的艺术

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存