"无 Typescript" 编程

18,736

一旦陷入 Typescript 就离不开它了。安全感、智能提示、代码重构..

但是还有很多旧的项目还在用着 JavaScript 呀?一时间很难迁移过来

使用 Typescript 最终还是要转译为 JavaScript 代码,我不想整这一套构建工具,喜欢直接写完直接跑、直接调试、直接发到 npm..

...


现代 VSCode 对 JavaScript 的类型推断(底层基于 Typescript )已经非常牛逼了:



但是还不够,不够霸道,不够果断,不然我们还要 Typescript 干嘛:

不能将 string 赋给 number 呀,我们希望这里有提示,但是没有

💡 本文编辑器基于 VSCode

💡本文需要一定的 Typescript 基础

💡推荐查看原文,更好的排版



为 JavaScript 开启类型检查

第一步,首先要确保你的 VScode 开启了 JavaScript 验证:



第二步,在文件顶部添加 // @ts-check

Bingo! VSCode 已经有类型报错了



第三步, 类型检查程序

如果你想通过程序来验证代码是否有类型错误,可以安装 Typescript CLI:

$ yarn add typescript -D

接着添加一个 jsconfig.json 文件,这个配置文件类似于 tsconfig.json , 配置参数(详见这里)也差不多,只不过 jsconfig 专注于 Javascript 。我们的配置如下:

{
  "compilerOptions": {
    "target": "esnext",
    "noEmit": true,             // 🔴关闭输出,因为我们只进行类型检查
    "skipLibCheck": true, 
    // "checkJs": true,         // 开启对所有 JS 文件的类型检查,不需要 @ts-check
    "strict": true,             // 🔴严格的类型检查
    "moduleResolution": "node", // 🔴按照 Node 方式查找模块
    "jsx": "react",             // 🔴开启 JSX
    "module": "esnext",
    "rootDir": "src",           // 🔴源文件目录
    "resolveJsonModule": true
  },
  "exclude": ["node_modules", "tests/**/*"]
}


💡 当然你直接用 tsconfig.json 也是没问题。tsconfig.json 中可以使用 allowJs 允许 JavaScript 进行处理,另外可以通过 checkJs 全局(相当于对所有JavaScript 文件都添加 @ts-check)开启对 JavaScript 的类型检查。如果你要渐进式地展开对 JavaScript 的类型检查和迁移,还是建议使用 @ts-check



接下来在 package.json 添加 run script,方便我们执行:

{
  "scripts": {
    "type-check": "tsc -p jsconfig.json",
    "type-check:watch": "tsc -p jsconfig.json --watch"
  },
}

Run it!

$ yarn type-check
tsc -p jsconfig.json
src/index.js:12:1 - error TS2322: Type '"str"' is not assignable to type 'number'.

12 ins.foo = 'str';
   ~~~~~~~

Found 1 error.

error Command failed with exit code 1.



渐进式类型声明

单纯依赖于类型推断,远远不够,Typescript 还没那么聪明,很多情况下我们需要显式告诉 Typescript 对应实体是什么类型。

在 JavaScript 中,我们可以通过 [JSDoc 注解](https://jsdoc.app/)或者 .d.ts 来进行类型声明。

下面尝试将 Typescript 中类型声明的习惯用法迁移到 JavaScript 中。



1. 变量

1⃣ 变量注解

比较简单,只是将类型注解放进 @type {X} 中, 类型注解的语法和 Typescript 保持一致:

const str = 'string' // 自动推断
let count: number
const member: string[] = []
const users: Array<{id: string, name: string, avatar?: string}> = []
let maybe: string | undefined
const str = 'string'; // 自动推断
/** @type {number} */
let count;
/** @type number ❤️ 括号可以省略,省得更简洁*/
let count;
/** @type {string[]} */
const member = [];
/** @type {Array<{ id: string, name: string, avatar?: string }>} */
const users = [];
/** @type string | undefined */
let maybe


2⃣ 类型断言

var numberOrString: number | string = Math.random() < 0.5 ? "hello" : 100;
var typeAssertedNumber = numberOrString as number
/**
 * @type {number | string}
 */
var numberOrString = Math.random() < 0.5 ? "hello" : 100;
var typeAssertedNumber = /** @type {number} */ (numberOrString); // 注意括号是必须的


2. 类型定义

有些类型会在多个地方用到,我们通常通过 interfacetype 定义类型,然后复用

1️⃣ 常规 interface

interface User {
  id: string;
  name: string;
  age: number;
  avatar?: string;
}
/**
 * JSDoc 注解通常是单行的
 * @typedef {{ id: string name: string age: number avatar?: string }} User 用户
 */

/** @type {User} */
var a

// 🔴不过多行也是可以被识别的
/**
 * @typedef {{
 *  id: string
 *  name: string
 *  age: number
 *  avatar?: string
 * }} User
 */

还有另外一种更 ‘JSDoc' 的类型定义方式, 它的好处是你可以针对每个字段进行文字说明,对编辑器智能提示或文档生成有用:

/**
  * @typedef {Object} User
  * @property {string} id 主键
  * @property {string} name 名称
  * @property {number} age 年龄
  * @property {string} [avatar] 头像
  */


2️⃣泛型

// 泛型
interface CommonFormProps<T> {
  value?: T;
  onChange(value?: T): void;
}

// 多个泛型
interface Component<Props, State> {
  props: Props;
  state: State;
}
// 泛型
/**
 * @template T
 * @typedef {{
 *  value?: T
 *  onChange(value?: T): void
 * }} CommonFormProps
 */

 /** @type {CommonFormProps<string>} */
 var b

// 多个泛型
/**
 * @template Props, State
 * @typedef {{
 *  props: Props;
 *  state: State;
 * }} Component
 */

/** @type {Component<number, string>} */
var d


3️⃣组合已存在类型

interface MyProps extends CommonFormProps<string> {
  title?: string
}
/**
 * @typedef {{title?: string} & CommonFormProps<string>} MyProps
 */

/** @type {MyProps}*/
var e


4️⃣类型别名

type Predicate = (data: string, index?: number) => boolean;
type Maybe<T> = T | null | undefined;
// 类型别名
/**
 * @typedef {(data: string, index?: number) => boolean} Predicate
 */

 /** @type {Predicate} */
 var f

/**
 * @template T
 * @typedef {(T | null | undefined)} Maybe
 */

/** @type {Maybe<string>} */
var g

💡 如果没有泛型变量,多个 @typedef 可以写在一个注释中:

/**
 * @typedef {(data: string, index?: number) => boolean} Predicate
 * @typedef {{
 *  id: string
 *  name: string
 *  age: number
 *  avatar?: string
 * }} User
 */
// 有泛型变量需要单独存在
/**
 * @template T
 * @typedef {(T | null | undefined)} Maybe
 */


如上,除了 @typedef 和 @template 一些特殊的语法,其他的基本和 Typescript 保持一致。


🙋🏻‍♂️那么问题来了!

  • 怎么将类型共享给其他文件?
  • 你嫌写法有点啰嗦
  • 代码格式化工具不支持对注释进行格式化
  • ...


5️⃣ 声明文件


实际上,我们可以通过 import 导入另一个模块中的类型定义:

// @file user.js
/**
 * @typedef {{
 *  id: string
 *  name: string
 *  age: number
 *  avatar?: string
 * }} User
 */
// @file index.js
/** @type {import('./user').User} */

一个更好的方式是,创建一个独立的 *.d.ts (Typescript 纯类型声明文件)来存放类型声明,比如 types.d.ts, 定义并导出类型:

// @file types.d.ts

export interface User {
  id: string;
  name: string;
  age: number;
  avatar?: string;
}

export interface CommonFormProps<T> {
  value?: T;
  onChange(value?: T): void;
}

export interface Component<Props, State> {
  props: Props;
  state: State;
}

export interface MyProps extends CommonFormProps<string> {
  title?: string
}

export type Predicate = (data: string, index?: number) => boolean;
export type Maybe<T> = T | null | undefined;
/** @type {import('./types').User} */
const a = {};

/** @type {import('./types').CommonFormProps<string>} */
var b;

/** @type {import('./types').Component<number, string>} */
var e;

/** @type {import('./types').MyProps}*/
var f;

/** @type {import('./types').Predicate} */
var g;

/** @type {import('./types').Maybe<string>} */
var h;

💡 如果某个类型被多次引用,重复 import 也比较啰嗦,可以在文件顶部一次性导入它们:

/**
 * @typedef {import('./types').User} User
 * @typedef {import('./types').Predicate} Predicate
 */
/** @type {User} */
var a;
/** @type {Predicate} */
var g;


6️⃣第三方声明文件

没错,import('module').type 也可以导入第三方库的类型。这些库需要带类型声明,有些库 npm 包中自带了声明文件(比如 Vue), 有些则你需要下载对应的 @types/* 声明文件(比如 React,需要装 @types/react)。

以 React 为例,需要安装 @types/react :

/**
 * @typedef {{
 *    style: import('react').CSSProperties
 * }} MyProps
 */

// 因为 @types/* 默认会暴露到全局,比如 @types/react 暴露了 React 命名空间,因此下面这样写也是可以的:
/**
 * @typedef {{
 *    style: React.CSSProperties
 * }} MyProps
 */


7️⃣全局声明文件

除此之外,我们也可以全局声明类型,在项目的所有地方都可以用到这些类型定义。按照习惯,我们在项目根目录(jsconfig.json 所在目录)创建一个 global.d.ts

// @file global.d.ts
/**
 * 全局类型定义
 */
interface GlobalType {
  foo: string;
  bar: number;
}

/**
 * 扩展已有的全局对象
 */
interface Window {
  __TESTS__: boolean;
  // 暴露 jquery 到 window, 需要安装 @types/jquery
  $: JQueryStatic;
}
/** @type {GlobalType} */
var hh                       // ✅
window.__TESTS__             // ✅
const $elm = window.$('#id') // ✅


3. 函数

接下来看看怎么给函数进行类型声明:

1️⃣可选参数

function buildName(firstName: string, lastName?: string) {
  if (lastName) return firstName + ' ' + lastName;
  else return firstName;
}

function delay(time = 1000): Promise<void> {
  return new Promise((res) => setTimeout(res, time));
}
// 🔴 JSDoc 注释风格
/**
 * @param {string} firstName 名
 * @param {string} [lastName] 姓,方括号是 JSDoc 可选参数的写法
 * @returns string 可选,Typescript 可以推断出来,如果无法推断,可以显示声明
 */
function buildName(firstName, lastName) {
  if (lastName) return firstName + ' ' + lastName;
  else return firstName;
}
buildName('ivan') // ✅

/**
 * @param {number} [time=1000] 延迟时间, 单位为 ms
 * @returns {Promise<void>}
 */
function delay(time = 1000) {
  return new Promise((res) => setTimeout(res, time));
}
// 🔴你也可以使用 Typescript 风格, **不过不推荐!**它有以下问题:
// - 不能添加参数注释说明, 或者说工具不会识别
// - 对可选参数的处理有点问题, 和 Typescript 行为不一致
/** @type {(firstName: string, lastName?: string) => string} */
function buildName(firstName, lastName) {
  if (lastName) return firstName + ' ' + lastName;
  else return firstName;
}

// ❌ 因为TS 将buildName 声明为了 (firstName: string, lastName: string | undefined) => string
buildName('1') 
// 🔴另一个可选参数的声明方法是 -- 显式给可选参数设置默认值(ES6的标准),TS 会推断为可选

/**
 * @param {string} firstName 名
 * @param {string} [lastName=''] 姓,方括号是 JSDoc 可选参数的写法
 * @returns string 可选,Typescript 可以推断出来,如果无法推断,可以显示声明
 */
function buildName(firstName, lastName = '' ) {
  if (lastName) return firstName + ' ' + lastName;
  else return firstName;
}

/** @type {(firstName: string, lastName?: string) => string} */
function buildName(firstName, lastName = '') {
  if (lastName) return firstName + ' ' + lastName;
  else return firstName;
}
buildName('1') // ✅



2️⃣ 剩余参数

function sum(...args: number[]): number {
  return args.reduce((p, c) => p + c, 0);
}
/**
 * @param  {...number} args 
 */
function sum(...args) {
  return args.reduce((p, c) => p + c, 0);
}


3️⃣ 泛型与泛型约束

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}
/**
 * @template T
 * @template {keyof T} K // 可以在 {} 中约束泛型变量的类型
 * @param {T} obj
 * @param {K} key
 */
function getProperty(obj, key) {
  return obj[key];
}


4️⃣ this 参数声明

interface User {
  name: string;
  lastName: string;
}
const user = {
  name: 'ivan',
  lastName: 'lee',
  say(this: User) {
    return `Hi, I'm ${this.name} ${this.lastName}`;
  },
};
/**
 * @typedef {{
 *   name: string;
 *   lastName: string;
 * }} User
 */
const user = {
  name: 'ivan',
  lastName: 'lee',
  /** @this {User} */
  say() {
    return `Hi, I'm ${this.name} ${this.lastName}`;
  },
};

这里基本覆盖了JavaScript 函数的基本使用场景,其余的以此类推,就不展开了。



4. 类

1️⃣ 常规用法

class Animal {
  private name: string;
  constructor(theName: string) {
    this.name = theName;
  }
  move(distanceInMeters: number = 0) {
    console.log(`${this.name} moved ${distanceInMeters}m.`);
  }
}

可以使用 @public、@private、@protected 来声明字段或方法的访问性。

// 🔴 如果目标环境支持 ES6 class
class Animal {
  /**
   * @param {string} theName
   */
  constructor(theName) {
    // 属性声明
    /**
     * @type {string}
     * @private 声明为私有, 对应的也有 @protected, 默认都是 @public
     * 当然也可以使用 ES6 的 private field 语言特性
     */
    this.name = theName;
  }
  /**
   * @param {number} [distanceInMeters]
   */
  move(distanceInMeters = 0) {
    console.log(`${this.name} moved ${distanceInMeters}m.`);
  }
}

const a = new Animal('foo');
a.name; // ❌ 不能访问私有字段
// 🔴 如果目标环境不支持,只能使用 函数形式了
/**
 * @constructor
 * @param {string} theName
 */
function Animal1(theName) {
  /**
   * @type {string} 这里不能使用 private 指令
   */
  this.name = theName;
}

/**
 * @param {number} [distanceInMeters = 0]
 */
Animal1.prototype.move = function (distanceInMeters) {
  console.log(this.name + ' moved ' + (distanceInMeters || 0) + 'm.');
};

const a = Animal1('bird') // ❌ 使用 @constructor 后,只能 new 调用



2️⃣ 泛型

import React from 'react';

 
export interface ListProps<T> {
  datasource: T[];
  idKey?: string;
}

export default class List<T> extends React.Component<ListProps<T>> {
  render() {
    const { idKey = 'id', datasource } = this.props;
    return datasource.map(
      (i) => React.createElement('div', { key: i[idKey] }) /*...*/,
    );
  }
}
// @ts-check
import React from 'react';

/**
 * @template T
 * @typedef {{
 *  datasource: T[];
 *  idKey?: string;
 * }} ListProps
 */

/**
 * @template T
 * @extends {React.Component<ListProps<T>>} 使用 extends 声明继承类型
 */
export default class List extends React.Component {
  render() {
    const { idKey = 'id', datasource } = this.props;
    return datasource.map(
      // @ts-expect-error JavaScript 中也可以使用 @ts-ignore 等注释指令
      (i) => React.createElement('div', { key: i[idKey] }) /*...*/,
    );
  }
}

// 显式定义类型
/** @type {List<{id: string}>} */
var list1;
// 自动推断类型
var list2 = <List datasource={[{ id: 1 }]}></List>;

⚠️ 不支持泛型变量默认值



3️⃣接口实现

interface Writable {
  write(data: string): void
}

class Stdout implements Writable {
  // @ts-expect-error ❌ 这里会报错,data 应该为 string
  write(data: number) {} 
}
/**
 * @typedef {{
 *   write(data: string): void
 * }} Writable
 */

/**
 * @implements {Writable}
 */
class Output {
  /**
   * @param {number} data 
   */
  write(data) { // ❌ data 应该为 string
  }
}


6. 其他

枚举

@enum 只能用于约束对象的成员类型,作为类型时没什么卵用

/** @enum {number} Mode*/
const Mode = {
  Dark: 1,
  Light: 2,
  Mixed: 'mix' // ❌
};

/**
 * @type {Mode}
 */
var mode = 3; // ✅,作为类型时,作用不大


@deprecated

class Foo {
  /** @deprecated */
  bar() {
  }
}

(new Foo).bar // ❌ 在 Typescript 4.0 会报错


总结

"无 Typescript 编程" 有点标题党,其实这里不是不用 Typescript,而是换一种无侵入的方式用 Typescript。

使用 JSDoc 注解进行类型声明的方式,基本上可以满足 JavaScript 的各种类型检查要求,当然还有很多 Typescript 特性不支持。

这样,利用 Typescript 带来的各种红利, 顺便培养自己写注释的习惯, JSDoc 注解也方便进行文档生成。何乐而不为



本文提及了以下 JSDoc 注解:

  • @type 声明实体类型、例如变量、类成员
  • @typedef 定义类型
  • @template 声明泛型变量
  • @param 定义函数参数类型
  • @returns 定义函数返回值类型
  • @constructor 或 @class 声明一个函数是构造函数
  • @extends 声明类继承
  • @implements 声明类实现哪些接口
  • @public、@private、@protected 声明类成员可访问性
  • @enum 约束对象成员类型
  • @deprecated 废弃某个实体
  • @property 和 @typedef 配合,声明某个字段的类型

除本文以及 Typescript 官方文档提及的 JSDoc 注解之外的其他注解,暂时不被支持。



接下来,可以看看 Typescript 的官方文档说明,继续探索 JSDoc,以及以下使用案例(欢迎补充):

如果你探索出更多玩法,欢迎评论告诉我。



扩展