JSDoc——类型化 JavaScript 的黎明

最早接触 JSDoc 是 2016 年领导要求给项目写 API 文档,最近一周被迫写 JavaScript 而不能写 TypeScript,又苦于 VS Code 的代码分析类型检查能力太弱,故祭出了 JSDoc 类型标注大法。

JSDoc 是 JavaScript 的文档生成工具,在 JavaScript 块注释的基础上提供了一套 tag 语法,如 @param {string} message,可以根据源码中的这些特殊格式注释生成 API 文档。其他编程语言中也有类似的注释,这种注释块(Comment Block)也被称为 Docblock (DocBlock)。

除了用于生成 API 文档,JSDoc 另一个功能是标记变量、入参、返回值的类型,稍微弥补了 JavaScript 没有静态类型,不支持类型标注的问题。基于 JSDoc 的类型注释,Google Closure Compiler 可以分析优化代码,VS Code 之类的代码编辑器可以在不使用 TypeScript 的情况下实现代码提示。

历史

Michael Mathews 在 2001 年创建 JSDoc 项目,它是最早的 JavaScript 文档工具,注释语法借鉴自 Javadoc(很多文档工具都使用了类似 Javadoc 注释语法,比如 C++ 的 Doxygen、 PHP 的 phpDocumentor)。截止 2020 年经历了 4 个大版本更新:

  • 2001 JSDoc (JSDoc.pm): 使用 Perl 编写,代码托管在 SourceForge
  • 2007 JsDoc Toolkit 1.0: 使用 JavaScript 编写,基于 Rhino,运行在 Java 平台,代码托管在 Google Code
  • 2008 JsDoc Toolkit 2.0: 同 1.0
  • 2011 JSDoc 3: 基于 Node.js,代码托管在 GitHub

类型标注

JSDoc 要求块注释以 /** 开头,在 /*/*** 开头的块注释,或者行注释 // 中使用会被忽略。

受编辑器(VS Code)限制,日常使用时,很少会使用到所有 JSDoc 支持的类型标注,另外像 Google Closure Compiler 之类的项目还基于 ECMAScript 4 草案实现了类型系统,定义了更丰富的类型标注。 这里主要介绍 TypeScript 支持的 JSDoc 标注语法,包含一部分常用的文档型标注。

涉及以下标注:

  • 类型
    • @type
    • @typedef
    • @property/@prop
    • @template
    • @enum
  • 函数
    • @param/@argument/@arg
    • @returns/@return
    • @throws
    • @deprecated
  • 类和继承
    • @constructor/@class
    • @this
    • @extends
    • @public
    • @private
    • @protected
    • @readonly
  • 文档
    • @fileoverview
    • @author
    • @example
    • @description
    • @see
    • @link
    • @license
    • @preserve

定义文件头和作者

/**
 * @fileoverview Manages the configuration settings for the widget.
 * @author Rowina Sanela
 */
/**
 * @author Jane Smith <jsmith@example.com>
 */
function MyClass() {}

定义链接

// Use the inline {@link} tag to include a link within a free-form description.
/**
 * @see {@link foo} for further information.
 * @see {@link http://github.com|GitHub}
 */
function bar() {}
/**
 * {@link namepathOrURL}
 * [link text]{@link namepathOrURL}
 * {@link namepathOrURL|link text}
 * {@link namepathOrURL link text (after the first space)}
 */

定义软件协议

/**
 * Utility functions for the foo package.
 * @license Apache-2.0
 */
/**
 * @license
 * Copyright (c) 2015 Example Corporation Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

定义使用示例

/**
 * Solves equations of the form a * x = b
 * @example
 * // returns 2
 * globalNS.method1(5, 10);
 * @example
 * // returns 3
 * globalNS.method(5, 15);
 * @returns {Number} Returns the value of x for the equation.
 */
globalNS.method1 = function (a, b) {
    return b / a;
};

定义废弃的方法、属性

/**
 * @deprecated since version 2.0
 */
function old() {
}

定义希望 minifier 保留的注释

/**
 * @preserve Copyright 2009 SomeThirdParty.
 * Here is the full license text and copyright
 * notice for this file. Note that the notice can span several
 * lines and is only terminated by the closing star and slash:
 */

定义单个类型

/**
 * @type {string}
 */
var s;

/** @type {Window} */
var win;

/** @type {PromiseLike<string>} */
var promisedString;

/** @type {HTMLElement} */
var myElement = document.querySelector(selector);
element.dataset.myData = "";
/**
 * @type {HTMLCollectionOf<HTMLElement>}
 */
const nodes = document.getElementsByClassName("className");

定义多个类型(联合类型,Union Type)

/**
 * @type {(string | boolean)}
 */
var sb;

// TypeScript 中,圆括号可以省略
/**
 * @type {string | boolean}
 */
var sb;

定义数组

/** @type {number[]} */
var ns;
/** @type {Array.<number>} */
var nds;
/** @type {Array<number>} */
var nas;

定义对象

/** @type {{ a: string, b: number }} */
var var9;
/**
 * A map-like object that maps arbitrary `string` properties to `number`s.
 *
 * @type {Object.<string, number>}
 */
var stringToNumber;

/** @type {Object.<number, object>} */
var arrayLike;

定义函数

/** @type {function(string, boolean): number} Closure syntax */
var sbn;
/** @type {(s: string, b: boolean) => number} TypeScript syntax */
var sbn2;
/** @type {Function} */
var fn7;
/** @type {function} */
var fn6;
/** @typedef {{ prop1: string, prop2: string, prop3?: number }} SpecialType */
/** @typedef {(data: string, index?: number) => boolean} Predicate */

/** @type {SpecialType} */
var specialTypeFn1;

/** @type {Predicate} */
var specialTypeFn2;
/**
 * @param {Object} options - The shape is the same as SpecialType above
 * @param {string} options.prop1
 * @param {number} options.prop2
 * @param {number=} options.prop3
 * @param {number} [options.prop4]
 * @param {number} [options.prop5=42]
 */
function special(options) {
  return (options.prop4 || 1001) + options.prop5;
}
// Parameters may be declared in a variety of syntactic forms
/**
 * @param {string}  p1 - A string param.
 * @param {string=} p2 - An optional param (Closure syntax)
 * @param {string} [p3] - Another optional param (JSDoc syntax).
 * @param {string} [p4="test"] - An optional param with a default value
 * @return {string} This is the result
 */
function stringsStringStrings(p1, p2, p3, p4) {
  // TODO
}
/**
 * @return {PromiseLike<string>}
 */
function ps() {}

/**
 * @returns {{ a: string, b: number }} - May use '@returns' as well as '@return'
 */
function ab() {}
var someObj = {
  /**
   * @param {string} param1 - Docs on property assignments work
   */
  x: function (param1) {},
};

/**
 * As do docs on variable assignments
 * @return {Window}
 */
let someFunc = function () {};

/**
 * And class methods
 * @param {string} greeting The greeting to use
 */
Foo.prototype.sayHi = (greeting) => console.log("Hi!");

/**
 * And arrow functions expressions
 * @param {number} x - A multiplier
 */
let myArrow = (x) => x * x;

/**
 * Which means it works for stateless function components in JSX too
 * @param {{a: string, b: number}} test - Some param
 */
var sfc = (test) => <div>{test.a.charAt(0)}</div>;

/**
 * A parameter can be a class constructor, using Closure syntax.
 *
 * @param {{new(...args: any[]): object}} C - The class to register
 */
function registerClass(C) {}

/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn10(p1) {}

/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn9(p1) {
  return p1.join();
}
/**
 * @throws {DOMException}
 */
DOMApplicationCache.prototype.swapCache = function() {
};

定义 Promise

只能定义 Promiseresolve 后的类型,暂不支持表示 reject

/**
 * Returns the sum of a and b
 * @param {number} a
 * @param {number} b
 * @returns {Promise} Promise object represents the sum of a and b
 */
function sumAsync(a, b) {
  return new Promise(function(resolve, reject) {
    resolve(a + b);
  });
}
/**
 * @return {PromiseLike<string>}
 */
function ps() {}

写成 async 函数,可以用 @throws 间接表示 reject

/**
 * @returns {Promise<string>}
 * @throws {Error}
 */
async function foo() {
}

定义 any 类型

/**
 * @type {*} - can be 'any' type
 */
var star;
/**
 * @type {?} - unknown type (same as 'any')
 */
var question;

定义复杂类型

/**
 * @typedef {Object} SpecialType - creates a new type named 'SpecialType'
 * @property {string} prop1 - a string property of SpecialType
 * @property {number} prop2 - a number property of SpecialType
 * @property {number=} prop3 - an optional number property of SpecialType
 * @prop {number} [prop4] - an optional number property of SpecialType
 * @prop {number} [prop5=42] - an optional number property of SpecialType with default
 */

/** @type {SpecialType} */
var specialTypeObject;
specialTypeObject.prop3;
/**
 * @typedef {object} SpecialType1 - creates a new type named 'SpecialType'
 * @property {string} prop1 - a string property of SpecialType
 * @property {number} prop2 - a number property of SpecialType
 * @property {number=} prop3 - an optional number property of SpecialType
 */

/** @type {SpecialType1} */
var specialTypeObject1;

可以从 TypeScript 中引入类型

// @filename: types.d.ts
export type Pet = {
  name: string,
};

// @filename: main.js
/**
 * @param { import("./types").Pet } p
 */
function walk(p) {
  console.log(`Walking ${p.name}...`);
}
/**
 * @typedef { import("./types").Pet } Pet
 */

/**
 * @type {Pet}
 */
var myPet;
myPet.name;
/**
 * @type {typeof import("./accounts").userAccount }
 */
var x = require("./accounts").userAccount;
/** @type {import('webpack').Configuration} */
module.exports = {};

定义范型

/**
 * @template T
 * @param {T} x - A generic parameter that flows through to the return type
 * @return {T}
 */
function id(x) {
  return x;
}

const a = id("string");
const b = id(123);
const c = id({});
/**
 * @template T,U,V
 * @template W,X
 */
/**
 * @template {string} K - K must be a string or string literal
 * @template {{ serious(): string }} Seriousalizable - must have a serious method
 * @param {K} key
 * @param {Seriousalizable} object
 */
function seriousalize(key, object) {
  // ????
}

定义类(Class)和继承

class C {
  /**
   * @param {number} data
   */
  constructor(data) {
    // property types can be inferred
    this.name = "foo";

    // or set explicitly
    /** @type {string | null} */
    this.title = null;

    // or simply annotated, if they're set elsewhere
    /** @type {number} */
    this.size;

    this.initialize(data); // Should error, initializer expects a string
  }
  /**
   * @param {string} s
   */
  initialize = function (s) {
    this.size = s.length;
  };
}

var c = new C(0);

// C should only be called with new, but
// because it is JavaScript, this is allowed and
// considered an 'any'.
var result = C(1);

支持旧的构造函数写法

/**
 * @constructor
 * @param {number} data
 */
function C(data) {
  // property types can be inferred
  this.name = "foo";

  // or set explicitly
  /** @type {string | null} */
  this.title = null;

  // or simply annotated, if they're set elsewhere
  /** @type {number} */
  this.size;

  this.initialize(data);
  // Argument of type 'number' is not assignable to parameter of type 'string'.
}
/**
 * @param {string} s
 */
C.prototype.initialize = function (s) {
  this.size = s.length;
};

var c = new C(0);
c.size;

var result = C(1);
// Value of type 'typeof C' is not callable. Did you mean to include 'new'?

继承的写法

/**
 * @template T
 * @extends {Set<T>}
 */
class SortableSet extends Set {
  // ...
}

访问修饰符(Access Modifiers)

class Car {
  constructor() {
    /** @private */
    this.identifier = 100;
  }

  printIdentifier() {
    console.log(this.identifier);
  }
}

const c = new Car();
console.log(c.identifier);
// Property 'identifier' is private and only accessible within class 'Car'.

只读属性

class Car {
  constructor() {
    /** @readonly */
    this.identifier = 100;
  }

  printIdentifier() {
    console.log(this.identifier);
  }
}

const c = new Car();
console.log(c.identifier);

定义 this

/**
 * @this {HTMLElement}
 * @param {*} e
 */
function callbackForLater(e) {
  this.clientHeight = parseInt(e); // should be fine!
}

定义枚举类型

/** @enum {number} */
const JSDocState = {
  BeginningOfLine: 0,
  SawAsterisk: 1,
  SavingComments: 2,
};

JSDocState.SawAsterisk;
/** @enum {function(number): number} */
const MathFuncs = {
  add1: (n) => n + 1,
  id: (n) => -n,
  sub1: (n) => n - 1,
};

MathFuncs.add1;

定义可选属性或者(非)空类型

/**
 * Use postfix question on the property name instead:
 * @type {{ a: string, b?: number }}
 */
var right;

空类型(nullable type)

/**
 * @type {?number}
 * With strictNullChecks: true  -- number | null
 * With strictNullChecks: false -- number
 */
var nullable;
/**
 * @type {number | null}
 * With strictNullChecks: true  -- number | null
 * With strictNullChecks: false -- number
 */
var unionNullable;

非空类型(Non-nullable type)

/**
 * @type {!number}
 * Just has type number
 */
var normal;

类型转换(Type Casting)

/**
 * @type {number | string}
 */
var numberOrString = Math.random() < 0.5 ? "hello" : 100;
var typeAssertedNumber = /** @type {number} */ (numberOrString);

吐槽

写注释是提高代码可读性(Code Readability)和项目可维护性的重要途径。奈何一部分程序员把“代码即注释”、“代码自解释(Self-Explanatory Code)”当作自己不写注释的理由,甚至声称“优秀的程序员不写注释”,这些想法实在是自欺欺人和迷之自信。以前端项目为例,但凡看过一眼 React/Vue/TypeScript 等热门项目代码,都不至于把“优秀的代码不用注释”作为自己的懒惰和不专业的借口。

附录1:JSDoc 相关技术时间表

JSDoc 的更迭见证了技术的革新变化,我们不妨再回顾下相关文档生成、类型检查工具的历史。

  • 1996 Javadoc (JavaDoc): Java 文档工具,包含在 JDK 1.0 中
  • 1997 Doxygen: C++ 文档工具
  • 1997 Rhino: 基于 Java 的 JavaScript 引擎
  • 1999 HeaderDoc: Apple 开源的文档生成器,支持 Javadoc 和 Doxygen 标签
  • 2001 JSDoc (JSDoc.pm): 使用 Perl 编写
  • 2002 phpDocumentor (phpdoc): PHP 文档工具
  • 2006 Google Web Toolkit (GWT): 允许开发者使用 Java 开发前端应用,最终编译为 JavaScript
  • 2007 JsDoc Toolkit 1.0: 使用 JavaScript 编写,基于 Rhino,运行在 Java 平台
  • 2008 ext-doc: ExtJS 注释处理工具,基于 Java
  • 2008 V8: Google 开源的 JavaScript 引擎
  • 2009 Google Closure Compiler (GCC): JavaScript 编译器,包含在 Google Closure Tools(曾被 Google 用于开发 Gmail、Google Docs、Google Maps)中
  • 2009 CoffeeScript: 由 Jeremy Ashkenas 开发,可以被转译为 JavaScript
  • 2009 Docco: CoffeeScript 文档生成器
  • 2009 Node.js: 基于 V8
  • 2010 JSDuck: Sencha 框架(Ext JS 4、Sencha Touch)使用的 API 文档生成器,使用 Ruby 编写
  • 2011 JSDoc 3: 基于 Node.js
  • 2011 Traceur Compiler: Google 开源的另一款 JavaScript 编译器,支持将 ES6+ 语法转译成 ES5 语法
  • 2011 Dart: Google 开源的编程语言,可以被转译为 JavaScript
  • 2011 YUIDoc: JavaScript 文档工具,包含在 Yahoo YUI 项目中
  • 2012 jsdox: JavaScript 文档生成器
  • 2012 TypeScript: Microsoft 开源的编程语言,JavaScript 的超集
  • 2013 React.PropTypes: 包含在 React 中,于 2013 年开源
  • 2014 Babel (6to5): JavaScript 转译器,2015 年由 6to5 更名为 Babel
  • 2014 Flow: Facebook 于 2014 年开源的静态类型检查器
  • 2015 ESDoc: JavaScript 文档生成工具,支持 ES6+ 语法
  • 2015 TypeDoc: TypeScript 文档生成器
  • 2016 API Extractor (@microsoft/api-extractor): TypeScript 分析工具,可以导出 API Report、.d.ts 类型定义、API 文档
  • 2018 TSDoc (AEDoc): 一份用于标准化 TypeScript 内文档注释的提案

在 Javadoc 诞生后发布的文档生成工具,注释语法多少都受到它的影响。在 Node.js 出现之前,JavaScript 工具链也大都基于 Java 平台的 Rhino JavaScript 引擎。

附录2:ECMAScript 时间表

JSDoc / Google Closure Compiler / TypeScript 等工具使用的类型标注和那份未发布的 ECMAScript 4 规范有着特殊的联系。

  • 1999 ECMAScript 3.0
  • 2007 ECMAScript 4 Draft
  • 2008 ECMAScript 3.1 Draft -> ECMAScript 5.0
  • 2009 ECMAScript 5.0
  • 2011 ECMAscript 5.1
  • 2013 ECMAScript 6 Draft
  • 2015 ECMAscript 6 (ECMAScript 2015)

相关链接