如何在JavaScript项目中使用TypeScript的能力

4,225 阅读20分钟

前言

标题这句话是什么意思呢,就是让 JavaScript 项目也能拥有 TypeScript 的类型检查代码补全的能力,拥有更接近 TypeScript 开发的体验。你一定很好奇怎么还能这样呢,下面就由我带大家来揭秘。 ​

其实大家平时在写代码的时候肯定也发现了像下面截图这样的情况:

image.png image.pngimage.png 你会发现 VS Code 会有类型提示,并且也有代码补全功能。这其实是利用了 TypeScript 类型推论(Type Inference) 和 VS Code 自动类型获取 能力。 ​

这些都是默认提供的,而后面介绍的是更进阶更完善的能力:包括类型声明、类型获取、类型检查、代码智能感知提示和补全。

配套代码

代码链接:github.com/chunjin666/… 使用方法:

  • 记得需要全局安装 TypeScript :npm i typescript -g,并且尽量保证版本在 4.5 及以上。
  • 在项目根目录下运行 npm run install
  • 鼠标移到对应的变量、方法上查看提示的类型。
  • 点击相应的属性等可以跳转到定义的地方。
  • 可删除 JSDoc 后对比下前后变化。
  • 在相关示例后实际编写代码体验代码提示、代码补全和类型检查的效果。
  • practice 部分添加了 4 个 JSDoc 使用示例,其中前 2 个比较简单,第 3 个中等,最后一个较复杂。

一 使用 JSDoc

上面我们已经了解到编辑器能通过代码中变量定义时的初始值以及上下广推断出了一部分类型,而且也可以增强类型检测来提升开发体验和代码质量。但是获取类型的能力有限,很多情况下是没有办法判断出来的。而我们正是可以利用TypeScriptJSDoc的支持来完善这个能力。 ​

JSDoc就是特定格式的 JavaScript 代码注释。它具有良好的兼容性,完全不影响代码在其他编辑器的识别。 ​

我们讲一下TypeScript 官方支持的 JSDoc 类型。 ​

下面这个脑图是按用途的方式来罗列了 JSDoc 中的标签,也可以按这个图来检索或者学习具体的标签。

JSDoc 脑图

JSDoc 的一般格式:

/**
 * 一般格式
 * @tagName {类型定义} typeNameOrPropName 说明描述
 */

Types

这一部分主要介绍如何定义各种类型(包括基础类型、复杂类型、联合类型等)、函数(参数、返回值),还有像泛型这样的能力。

@type

可以通过 @type 标签引用类型来声明一个变量,可以使用的类型来源包括:

  • 原始类型:如 string number boolean
  • 在 .d.ts 类型声明文件中的类型(包括全局的和 import 的)
  • 通过 JSDoc 的 @typedef 标签声明的
原始类型 primitive types
/**
 * @type {string}
 */
let str

/**
 * @type {number}
 */
let num

/**
 * @type {boolean}
 */
let boo

/**
 * @type {symbol}
 */
let syb
环境提供的类型
/**
 * @type {Window}
 */
let win

/**
 * @type {Date}
 */
let date

/** @type {PromiseLike<string>} */
var promisedString
联合类型 union type
/**
 * @type { string | boolean }
 */
var sb
数组类型
// 有两种写法,推荐使用第一种,写起来方便
/**
 * @type {number[]}
 */
let numArray1

/**
 * @type {Array<number>}
 */
let numArray2
自定义对象类型
/**
 * @type {{a: string, b: number}}
 */
let someObj
类 Map 对象
// 以下3种写法等价,推荐使用第二、三种(与 TypeScript 中写法想同)
/**
 * @type {Object<string, number>}
 */
let mapLike1

/**
 * @type {Record<string, number>}
 */
let mapLike2

/**
 * @type {{ [k: string]: number}}
 */
let mapLike3

// 还支持这种写法, 不过那么麻烦干嘛,TypeScript 也不支持这种写法
/** @type {Object.<string, number>} */
let mapLike4
类数组对象
// 以下2种写法等价
/**
 * @type {Record<number, any>}
 */
let arrayLike1

/**
 * @type {{[k: number]: any}}
 */
let arrayLike2
函数类型
// 支持下面2种写法
/**
 * @type {function(string, boolean): number} 闭包语法(Closure)
 */
let fun1
/**
 * @type {(s: string, b: boolean) => number} TypeScript语法
 */
let fun2

// 或者使用笼统的 `Function` 类型
/** @type {Function} */
let fun3
TypeScript 中的类型/工具类型
/**
 * @type {PropertyKey} 等于 string | number | symbol
 */
let key

/**
 * @typedef {{ a: number , b: string}} OriginalObject
 */
/**
 * @type {Partial<OriginalObject>}
 */
let partialObj

let str1 = 'test'
/**
 * @type {typeof str1}
 */
let willBeString

@param @returns

@param@returns用来描述一个函数的参数和返回值。

// 参数可以用多种语法格式来书写
/**
 * @param {string}  p1 - 一个string类型参数.
 * @param {string=} p2 - 一个可选的参数 (Google Closure syntax)
 * @param {string} [p3] - 另一个可选参数 (JSDoc syntax).
 * @param {string} [p4="test"] - 有默认值的可选参数
 * @returns {string} 返回值
 */
function stringsStringStrings(p1, p2, p3, p4) {
  return p1 + p2 + p3 + p4
}

返回值也可以使用 @return 不过推荐使用 @returns

@typedef

@typedef 用来定义一个类型,这个类型可以被其他地方引用。

@typedef@param 写法很相似。

/**
 * @typedef {Object} SpecialType - 创建一个类型名为 'SpecialType'
 * @property {string} prop1 - SpecialType 的一个 string 类型属性
 * @property {number} prop2 - SpecialType 的一个 number 类型属性
 * @property {number=} prop3 - SpecialType 的一个可选 number 类型属性
 * @prop {number} [prop4] - SpecialType 的一个可选 number 类型属性
 * @prop {number} [prop5=42] - SpecialType 的一个有默认值的可选 number 类型属性
 */

/**
 * @param {SpecialType} value
 */
function doSomethingWithSpecialType(value) {
  console.log(value.prop3)
}

第一行也可以用 object (o 小写),不过个人一般使用 Object(遵循 TypeScript 习惯)

/**
 * @typedef {object} SpecialType1 - 创建一个类型名为 'SpecialType'
 * @property {string} prop1 - SpecialType 的一个 string 类型属性
 * @property {number} prop2 - SpecialType 的一个 number 类型属性
 * @property {number=} prop3 - SpecialType 的一个可选 number 类型属性
 */

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

@callback

@callback@typedef 也比较相似,不过它是用来定义一个 function 类型的。

/**
 * @callback Predicate
 * @param {string} data
 * @param {number} [index]
 * @returns {boolean}
 */

/** @type {Predicate} */
const ok = (s, n) => !(s.length % (n || 2))
ok('dddd', 3)

另外,上面这些其实都可以通过 @typedef 在一行里面写完

/** @typedef {{ prop1: string, prop2: string, prop3?: number }} SpecialType2 */
/** @type { SpecialType2 } */
let sp2 = {
  prop1: 'hello',
  prop2: 'world',
}

/** @typedef {(data: string, index?: number) => boolean} Predicate1 */
/** @type { Predicate1 } */
let pd1 = (data, index) => !(data.length % (index || 2))

@param 其他用法

当参数是一个对象的时候,也可以使用类似 @typedef 的语法来写

/**
 * @param {Object} options - 格式和上面的 SpecialType 差不多
 * @param {string} options.prop1 - options 的一个 string 类型属性
 * @param {number} options.prop2 - options 的一个 number 类型属性
 * @param {number=} options.prop3 - options 的一个可选 number 类型属性
 * @param {number} [options.prop4] - options 的一个可选 number 类型属性
 * @param {number} [options.prop5=42] - options 的一个有默认值的可选 number 类型属性
 */
function special(options) {
  return (options.prop4 || 1001) + (options.prop5 || 0)
}

可以通过下面这种写法声明不定参数列表

/** @param {...number} args */
function sumOf() {
  var total = 0
  for (var i = 0; i < arguments.length; i++) {
    total += arguments[i]
  }
  return total
}

Casts 类型投影?类型转换?

相当于 TypeScript 中的 as 类型断言,可以把类型变得更精确,或者变成 any、unknown。格式为:/** @type {YourType} */ (variableOrExpression),注意后面的变量或者表达式需要加小括号。另外还需要注意,这个特性需要 TS 版本在 4.5 及以上才能支持(之前因为没发现这个问题,导致好几个月用不了一直耿耿于怀。。)。

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

// 可以像 TypeScript 一样投影成一个const类型

let one = /** @type {const} */ (1)

let oneStr = /** @type {const} */ ('1')

const deepMap = /** @type {const} */ ({
  lv1key1: 1,
  lv1key2: {
    lv2key1: 11,
  },
})

在 TS 里面可以通过在对象定义后面添加 as const 来把每一个 key 对应的值类型变为具体的字面量类型,在 JS 里面也可以通过 类型投影注释 来实现,写法为:/** @type {const} */ (variableOrExpression)

TS as const

const UserA = {
  name: 'usera', // type: string
  age: 18, // type: number
}

const UserB = {
  name: 'userb', // type: 'userb'
  age: 18, // type: 18
} as const

JS as const:

// 借助 Object.freeze 实现
const UserA = Object.freeze({
  name: 'usera', // type: 'usera'
  age: 18, // type: 18
})

// 借助 类型投影 实现
const UserB = /** @type {const} */ ({
  name: 'userb', // type: 'userb'
  age: 18, // type: 18
})

typeof

当某个值的类型比较复杂写起来比较麻烦时可以用typeof来获取其类型。

const userAccountDefault = {
  id: 1,
  username: 'name1',
  account: 'account',
  age: 18,
  isLogin: false,
}

/**
 *
 * @param {typeof userAccountDefault } account
 */
export function setAccount(account) {
  // TODO
}

type import 类型导入

  • 可以从 .d.ts 文件中导入类型。
// types.d.ts
export type Pet = {
  name: string
  age: number
}

// other js files
/**
 * @param {Pet} p
 */
function walk(p) {
  console.log(`Walking ${p.name}...`)
}

// 可以导入之后用来定义其他类型
/**
 * @typedef { import("./types").Pet } Pet
 */

/**
 * @type {Pet}
 */
var myPet
  • 也可以导入其他 js 文件中定义的类型。
// accounts.js
/**
 * @typedef {'normal' | 'premium'} AccountType
 */

const userAccountDefault = {
  id: 1,
  username: 'name1',
  account: 'account',
  age: 18,
  isLogin: false,
  login() {
    this.isLogin = true
  },
  logout() {
    this.isLogin = false
  },
}
export { userAccountDefault }

// other js files
// 可以导入 @typedef 定义的类型!!!
// 不过 eslint 如果开启了 no-unused-vars 会提示错误。
// 可以添加 // eslint-disable-next-line no-unused-vars
import { AccountType } from './accounts'

/**
 * @type {AccountType}
 */
var accountType = 'normal'

// 也可以在使用的地方直接导入类型
/**
 * @type {import('./accounts').AccountType}
 */
var accountType2

@template

可以使用@template标签声明一个类型参数,这可以让 函数、类、类型变为泛型。

/**
 * @template T
 * @param {T} x - 一个流转到返回类型的泛型参数
 * @return {T}
 */
function id(x) {
  return x
}

const ta = id('string')
const tb = id(123)
const tc = id({})

可以使用多个标签声明多个类型参数。

/**
 * @template T,U,V
 * @template W,X
 */

可以在类型参数名前指定一个类型限制。

/**
 * @template {string} K - 必须是一个字符串或者字符字面量
 * @template {{ execute(s: string): string }} Executable - 必须有一个 execute 函数,参数和返回值为字符串
 * @param {K} key
 * @param {Executable} executable
 */
function execute(key, executable) {
  executable.execute(key)
}

指定类型限制的另一个示例。

/**
 * @typedef {{a: string, b: number}} BaseOption
 */
/**
 * @template {{c: boolean}} T
 * @typedef {BaseOption & T} MergeOption
 */

/**
 * @type {MergeOption<{c: boolean, d: number}>}
 */
const option = {}

可以这样指定一个默认值给类型参数,不过这种写法编辑器还不支持。。。

/** @template [T=object] */
class Cache {
  /** @param {T} initial */
  constructor(T) {}
}
let c = new Cache()

函数重载

函数重载就是一个函数的不同用法,最终效果如下

image.png

image.png

官方文档上没有写实现方式,不过从 TypeScript 的 issues 中找到了。

github.com/microsoft/T…

方式一
/**
 * @param {number} acc
 * @param {number} cur
 */
const sumReducer = (acc, cur) => acc + cur

/**
 * @type {{
 * (nums: number[]): number
 * (...nums: number[]): number
 * }}
 */
const sum = (...nums) => {
  if (Array.isArray(nums[0])) {
    return nums[0].reduce(sumReducer, 0)
  }
  return /** @type {number[]} */ (nums).reduce(sumReducer, 0)
}

sum(1, 2, 3)
sum([1, 2, 3])
方式二
/**
 * This function takes a number and a string.
 * @callback signatureA
 * @param {number} num
 * @param {string} str
 */

/**
 * This function takes a boolean and an object
 * @callback signatureB
 * @param {boolean} bool
 * @param {Object} obj
 */

/**
 * @param {Parameters<signatureA> | Parameters<signatureB>} args
 */
function overloaded(...args) {}

overloaded(1, 'foo')
overloaded(true, { foo: 'bar' })

Classes

这一部分主要讲与类相关的一些用法

Property Modifiers 属性描述符

可以把属性标记为公有、保护、私有类型。

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

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

const car = new Car()
console.log(car.identifier) // 属性“identifier”为私有属性,只能在类“Car”中访问。
console.log(car.innerName) // 属性“innerName”为私有属性,只能在类“Car”中访问。

@public 是默认的可以省略不写, 代表着一个属性可以在任何地方访问到. @private 代表一个属性只能在类内部被访问. @protected 代表一个属性只能在类内部或者所有子类中访问到. @public, @private, @protected 不能对构造函数生效.

@readonly

@readonly标识符表示一个属性只能在构造函数初始化的过程中被写入。

class Car1 {
  /** @readonly */
  readonlyProp = true
  constructor() {
    /** @readonly */
    this.identifier = 100
  }

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

  someMethod() {
    this.readonlyProp = false
    // 无法分配到 "readonlyProp" ,因为它是只读属性。
  }
}

const car1 = new Car1()
car1.identifier = 0
// 无法分配到 "identifier" ,因为它是只读属性。

@override

@overrideTypeScript 一样,在要覆盖父类的同名方法的时候使用。

jsconfig.json 中设置 noImplicitOverride: true 查看效果,该设置的意思是:不要有隐式的覆盖。

export class C {
  m() {}
}

class D extends C {
  // 开启 noImplicitOverride 设置后,如果不加 @override 会提示错误
  /** @override */
  m() {}
}

@extends

当在 JavaScript 中继承一个基础类型的时候,可以通过 @extends 添加一个类型参数。普通继承的时候不需要添加这个标签就已经能识别了。

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

@augments

与上面的 @extends 类似,用在继承时。@augments 用来指定父类的类型参数(泛型参数)。

例如:React.Component 定义了 2 个类型参数 PropsState,在 .js 文件中,没有合法的语法来声明这 2 个类型的参数类型。默认情况下类型会被当成 any 来处理。

import { Component } from 'react'
class MyComponent1 extends Component {
  render() {
    this.props.b // 允许这么写, 因为 this.props 类型被当成 any
  }
}

但是,如果我们使用 @augments 来指定父类的类型参数,就可以指定父类的类型参数。

import { Component } from 'react'
/**
 * @typedef {{ a: number }} State
 */
/**
 * @augments {Component<{b: number}, State>}
 */
class MyComponent2 extends Component {
  render() {
    this.props.b // Ok
    this.props.c // Error: 类型“Readonly<{ b: number; }> & Readonly<{ children?: ReactNode; }>”上不存在属性“c”。
  }
}

@implements

通过 @implements 可以像 TypeScript 一样标记该类实现了一个接口。如果没有完整实现接口会提示错误。

/**
 * @implements {HTMLElement}
 */
class CustomElement {
  /**
   * @type {string}
   */
  accessKey

  click() {}
  // ...
  constructor() {
    this.accessKey = 'custom'
  }
}

@constructor

@constructor 用来标记一个函数是构造函数,如果使用的时候不是用 new 来使用会提示错误。

/**
 * @constructor
 * @param {number} data
 */
function C(data) {
  // 属性类型会被自动推断
  this.name = 'foo'

  // 可以明确设置类型
  /** @type {string | null} */
  this.title = null

  // 如果值在其他地方被设置了,可以简单的注释一下
  /** @type {number} */
  this.size

  this.initialize(data)
  // 类型“number”的参数不能赋给类型“string”的参数。
}

/**
 * @param {string} s
 */
C.prototype.initialize = function (s) {
  this.size = s.length
}

var c1 = new C(0)
c1.size

var result = C(1)
// 类型“typeof C”的值不可调用。是否希望包括 "new"?

添加 @constructor 之后,可以检查到构造函数 C 中的 this 的类型,所以调用 initialize 方法的时候传入不符合的 number 类型参数会提示错误,而且当你调用 C 的时候没有加 new 也会提示错误。

@this

通常情况下编译器能通过函数上下文确定 this 的类型,如果某些情况下不能确定的时候,可以通过 @this 明确指明它的类型。

/**
 * @this {HTMLElement}
 */
function clearEventHandler() {
  this.onclick = null
  this.onblur = null
  this.onfocus = null
}

const someEl = document.querySelector('#id_xxx')
clearEventHandler.call(someEl)
// 也可以把 clearEventHandler 添加到 HTMLElement 的原型上来使用

Enumeration 枚举

通过 @enum 标签可以在 JavaScript 中近似的使用 TypeScript 中的 enum 能力。通常用在定义一组常量的时候。标记为枚举后,这个常量名可以当成类型来使用。这个类型代表它的枚举值的联合类型。

/** @enum {number} */
const OrderState = {
  NotPaid: 0,
  Paid: 1,
  Failure: 2,
}

/**
 *
 * @param {OrderState} state
 */
function checkOrderState(state) {
  switch (state) {
    case OrderState.NotPaid:
      // ...
      break
    case OrderState.Paid:
      // ...
      break
    case OrderState.Failure:
      // ...
      break
    default:
      throw Error('invalid state')
  }
}

checkOrderState(OrderState.NotPaid) // Ok
checkOrderState(-1) // Error

我们知道在 TS 里面枚举可以当对象使用来取其中一种类型的值,也可以当成类型来使用,用来约束为只能是枚举里面的类型。

enum EnvTypes {
  DEV = 'DEV',
  STAGING = 'STAGING',
  PROD = 'PROD',
}

// 前一个 EnvTypes 是作为类型来使用,等同于 'DEV' | 'STAGING' | 'PROD'
// 后一个 EnvTypes 是作为变量来使用
let currentEnv: EnvTypes = EnvTypes.DEV

在上面 JS 的写法中,EnvTypes 值的内容现在只是 number 类型,而我们想要精确到具体的值(字面量)的类型。

EnvTypes 作为变量这一部分,我们可以通过添加一个 Object.freeze 让具体的值变为字面量类型,或者可以通过前面介绍的 类型投影 来做。

EnvTypes 作为枚举值的联合类型这个就有点不好办了,很长一段时间一直是通过手写每一个联合类型来解决的,不过有一天突发奇想使用了类型推断解决了这个问题。

  1. 定义一个工具类型 ValueOf,使用类型推断从具体对象里面提取出每个 Key 对应的值的字面量类型,并组合成一个联合类型。
  2. 正好使用 typeof 操作符提取 EnvTypes 的类型传入 ValueOf 得出结果。
  3. 这个工具类型可以放到一个 .d.ts 文件中,方便其他地方直接使用,也可以使用 JSDoc 的方式定义到 JS 文件中,不过这种方式其他文件要使用的话需要先 import 。
/**
 * @enum {ValueOf<typeof EnvTypes>}
 */
const EnvTypes = Object.freeze({
  DEV: 'DEV',
  STAGING: 'STAGING',
  PROD: 'PROD',
});

/**
 * @type {Record<EnvTypes, {baseURL: string, title?: string}>}
 */
const AppConfig = {
  [EnvTypes.DEV]: { baseURL: '/dev', title: 'xxx' },
  [EnvTypes.STAGING]: { baseURL: '/stage', title: 'yyy' },
  [EnvTypes.PROD]: { baseURL: '/prod', title: 'zzz' },
};

/** @type {EnvTypes} */
let env

// 这里能正确推断出返回值的类型
function getAppConfig() {
  return AppConfig[env]
}

.d.ts 文件内容,可以直接新建一个 utils.d.ts 放到项目根目录下

type ValueOf<T extends object, K extends keyof T = keyof T> = K extends K ? T[K] : never;

JSDoc 版 ValueOf 工具类型实现:

/**
 * @template T
 * @template {keyof T} [K= keyof T]
 * @typedef {K extends K ? T[K] : never} ValueOf
 */

Documentation

@deprecated

如果一个 API 即将废弃,可以添加上 @deprecated 标记,在使用的时候编辑器会有提示。

/** @deprecated */
const apiV1 = {}
const apiV2 = {}

image.png image.png

@see @link

@see 和 @link 用来提示用户查看相关类型或者接口的定义。

/**
 * 这2种方式都可以链接到 bar 函数。说是这么说,但实测下面这种方式不行。
 * @see {@link bar}
 * @see bar
 */
function foo() {}

// 可以使用行内的 {@link} 标记去添加一个链接,可以比较自由的访问其他描述信息。
/**
 * @see {@link foo} for further information.
 * @see {@link [http://github.com](http://github.com)}
 */
function bar() {}

image.png image.png

Others

@author

可以用来注明作者信息。

/**
 * Welcome to awesome.ts
 * @author Ian Awesome <i.am.awesome@example.com>
 */
export class awesome {
  constructor() {
    console.log('awesome')
  }
}

image.png

一些其他写法

// 这种基本不用管
/**
 * @type {*} - can be 'any' type
 */
var star
/**
 * @type {?} - unknown type (same as 'any')
 */
var question

已知的一些坑

// 单行注释只能添加一种类型
// 比如下面一行里面既有描述又有 @type,会导致无法识别
/** 坑 @type {string} */
var keng

二 识别和利用 JavaScript 库中的类型

通过 JSDoc 的使用,编辑器已经能够识别我们的 JavaScript 代码中的类型了,那我们平时还会使用到其他的一些 JavaScript 库,那这一部分的类型如何获取呢?

使用 .d.ts 类型声明文件

d.ts 文件是 TypeScript 类型声明文件,TypeScript 的设计目标之一是让你在 TypeScript 中安全、轻松地使用现有的 JavaScript 库,TypeScript 通过声明文件来做到这一点。

那么如何使用类型声明文件呢?

  • 手动编写(先不考虑了)
  • 使用 npm 上已有的文件

如何查找类型声明文件?

一般情况下,npm 包的类型声明文件包名和它本身在 npm 上的库名是相同的,只是需要加一个@types/前缀,所以你可以通过添加前缀的方式在 npm 官方网站 上搜索相应的包。 image.png

此外,你也可以到 TypeScript 官网上搜索相应的包。 image.png

安装类型声明文件

和正常的 npm 包安装并无什么不同,例如安装微信小程序声明文件包:

npm install --save-dev @types/wechat-miniprogram

大部分库不用考虑引入声明文件

使用 VS Code 其实大部分的库不需要我们手动安装,TypeScript 提供了包括 DOM、ES3 到 ESNext 等语言环境层面的 API 声明文件,另外还包括 node、lodash 等几百个库的声明文件。 image.png 另外现在很多库都自带了类型声明文件。比如 axiosimage.png

从 JavaScript 文件生成 .d.ts 类型声明文件

TypeScript 3.7 开始,支通过 JavaScript 中的 JSDoc 语法生成 .d.ts 类型声明文件。

生成方式

利用 TypeScript 的 tsc 命令来生成。详细使用方法可以查看 官方文档 。下面是官方示例的生成命令。

npx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly --outDir types

查找 JavaScript 类型声明文件规则

编辑器通过检查 package.json 中的 types 字段和 main 字段来确定要使用的类型声明文件。

Package.json默认 .d.ts 文件
没有 "types" 字段检查 "main"字段,然后找 index.d.ts
"types": "main.d.ts" // 有指定文件直接使用指定文件:main.d.ts
"types": "./dist/main.js" // 指定了 js 文件使用指定位置下的同名.d.ts 文件 ./dist/main.d.ts
没有 "main" 字段使用 index.d.ts
"main": "index.js" // 指定了 js 文件使用 index.d.ts
"main": "./dist/index.js"使用指定位置下的同名.d.ts 文件 ./dist/index.d.ts

让自己发布的 JavaScript 库的类型能被识别

假如我们自己要发布一个 JavaScript 库并且提供类型识别能力呢?有 2 种途径:

  • 生成并一起发布 .d.ts 类型声明文件。
  • 保留 JavaScript 代码中的 JSDoc 注释,编辑器是能识别的。

通过 JSDoc 发挥 JavaScript 库类型的作用

提升配置文件编写体验

在我们进行一些配置的时候,比如 webpack, vue,可以添加上类型注释来获得代码提示和补全。

// webpack.config.js

/**
 * @type {import('webpack').Configuration}
 */
module.exports = {
  // ...
}

image.png

// vue.config.js

/**
 * @type {import('@vue/cli-service').ProjectOptions}
 */
module.exports = {
  // ...
}

image.png

在代码中引用 JavaScript 库类型

比如下面添加类型注释之后,使用 wxp 就和使用 wx 一样,有代码提示和补全了:

import { promisifyAll } from 'miniprogram-api-promise'
/**
 * promise版 wx 对象
 * @type {WechatMiniprogram.Wx}
 */
const wxp = {}
promisifyAll(wx, wxp)

export default wxp

三 配置 jsconfig.json

jsconfig.json 配置可以帮我们提升编辑器的智能感知能力、打开类型检查以及配置类型检查的严格程度等。

jsconfig.json 是什么?

目录中存在jsconfig.json文件时,表明该目录是 JavaScript 项目的根目录。jsconfig.json文件指定了根文件以及 JavaScript 语言服务 提供的功能选项。

Tip: jsconfig.json源于 TypeScript 的配置文件 tsconfig.json。相当于tsconfig.jsonallowJs属性设置为true

打开类型检查配置

在项目 jsconfig.json 中设置checkJs选项为true

{
  "compilerOptions": {
    "checkJs": true
  }
}

开启之后,我们会发现 JavaScript 代码已经有了类型检查。 image.png image.png

其他设置方式

  1. 通过 VS Code 配置来打开类型检查: image.png

  2. 通过在 js 文件中添加注释来打开或者关闭类型检查

// 放在文件顶行,将检查整个文件
// @ts-check

// 放在文件顶行,将跳过整个文件的检查
// @ts-nocheck

// 会忽略下一行的错误
// @ts-ignore

其他配置

推荐配置,直接开启了支持最新语法及严格模式。严格模式在 js 中没有那么严格,所以其实也不会造成那么大的影响。

{
  "compilerOptions": {
    "target": "ESNext", // 可以使用最新的语法特性
    "module": "ES6",
    "moduleResolution": "node",
    "checkJs": true, // .js文件开启类型检查
    "strict": true, // 打开一系列的严格检查
    "alwaysStrict": true, // 为每个文件前面加上 "user strict" 。
    "noImplicitOverride": true, // 子类不要有隐式的同名覆盖,要覆盖得加 @override
    "noImplicitReturns": true // 如果添加了 @returns,代码中又有分支没有返回值是会提示错误
  }
}

如果有时候确实觉得错误提示解决起来很麻烦,可以把 "noImplicityAny" "strictNullChecks" 选项设置为 false,或者把更多的检查项关闭。

四 VS Code

开启内联提示

可以在代码中显示一些返回值的类型提示、方法参数名称提示等。 下图为全部开启后的效果,可以看到跟 ts 文件很像了,不过我这个主题看起来有点显眼,有的选项开启后还是有点干扰阅读代码。 image.png 在设置中搜索 Inlay 即可找到相应配置项,可以根据需要公开启一部分。下图是我的配置可供参考。 image.png

JSDoc 自动生成

编辑器可以通过上下文(包括根据方法传参、参数使用等)自动生成 JSDoc 类型提示。 可以看到有的地方首字母下方有...标记,鼠标移上去会出现 “快速修复”按钮,点击之后就能看到有推导类型的选项。 image.png image.png 下面为整个过程的动图: 根据上下文自动生成JSDoc.gif

实战

配套代码库提供了 4 个示例。可以从每个示例的 index.js 文件入手,一点点去查看变量、方法、参数、返回值的类型,然后再去看具体是怎么实现的。

form-rules

里面用到了 @typedef @callback @param @returns 这几个标签,还涉及到了 TS 中的工具类型 Record 以及 元组类型的用法。

enum

里面用到了 @enum @type @param 这几个标签,基本上可以实现 TypeScript 中 enum 的用法。

store

这算是一个综合的示例,是一个微信小程序环境下状态管理示例,里面用到了平时开发中经常会用到的大部分 JSDoc 标签。其中 createStore 会把配置对象中的函数变成 mbox 中的 action,并且生成的 store 对象依然保留了直接访问属性和方法的能力; createStoreBehavior 利用 behaviors (相当于 Vue 的 mixin) 的能力,可以把 store 的 属性和 action 映射到 Page 对象或者 Component 对象上,方便在 wxml 中直接使用。

wx-axios

这个示例相对稍复杂,wx-axios 目录下是对 axios 库在微信小程序环境下大部分功能的封装。除了其他使用的 JSDoc 标签外,还大量使用到了提供泛型能力的 @template 标签。这里可以关注一下封装后,在写请求时,options 参数可以提示自定义格式的参数(比如:showLoading, loadingMsg, resultValidator 等),.then 的结果里面也能获取到是有一个 data 属性。然后进一步可以再去看一下里面是怎么实现的。

补充——在Vue2 SFC 文件中使用

这一部分由于准备时间不够充分,就没有在代码库中添加示例代码了。下面的示例会提示“'foo' is not assignable to number”的错误。

<template>
  <div>{{ numOnly(post.body) }}</div>
</template>

<script>
/**
 * @typedef {object} Post
 * @property {string} body
 */

export default {
  props: {
    post: {
      /**
       * @type {import('vue').PropType<Post>}
       */
      type: Object,
      required: true
    }
  },

  methods: {
    /**
     * @param {number} num
     */
    numOnly(num) {

    }
  }
}
</script>

更详细的用法可以查看 Vetur官方文档 的说明。

总结

下面把使用 JavaScript + JSDocTypeScript 进行开发作一下对比:

项目JavaScript + JSDocTypeScript
便利性★★★★★★★★
类型完善度★★★★☆★★★★★
类型化开发可渐进式一般有较严格的要求
可以直接运行✖(需要编译)

从上面的整个内容看下来,通过对于 TypeScript 类型推断能力的使用,加上 JSDoc 注释,以及编辑器提供的其他能力,JavaScript 代码也具备了大部分 TypeScript 的能力,看起来也比较接近了。而 TypeScript 项目中也是可以使用 JavaScript 文件的,而且很多可以自动推断的类型就可以不用写定义。就感觉有一种在互相融合互相提升的感觉。 ​

虽然我们可以通过一系列手段让 JavaScript 代码也具有了大部分 TypeScript 一样的能力,基本上能够实现较为完善的代码补全与提示、类型安全编程,能让我们更方便的编写健壮、更易于维护的代码。所以还是非常值得去使用起来的。 ​

同时跟 TypeScript 对比起来,它的功能也没有特别完善,编写起来还是稍麻烦,所以我个人觉得应该好好的利用这些能力,而不用过于执着,找到一个比较好的平衡点。 ​

个人觉得使用 JSDoc 有以下几个理由可供参考:

  • 适合希望提高 JavaScript 代码健壮性、可维护性和编码信心的情况。
  • 适合习惯强类型语言开发的开发人员。
  • 另外可以在使用的过程中学习 TypeScript 知识。

比较适合使用 JSDoc 的场景有以下几个:

  • 业务项目中的公共部分、可能被多次使用的部分或者希望加强类型能力的部分。比如(配置项、工具函数、数据模型定义等)。
  • 小型或复杂度不高的公共库项目。

image.png