命名空间、声明文件

模块系统

目前主流的模块化解决方案有两种,分别是 ES6 模块化和 CommonJS 模块化。

TS 对这两种模块系统都有比较好的支持。

ES6 模块化

// a.ts

// 导出
export const a = 1

// 批量导出
const b = 2
const c = 3
export { b, c }

// 导出接口
export interface P {
  x: number
  y: number
}

// 导出函数
export function f() {}

// 导出时起别名
function g() {}
export { g as G }

// 默认导出,无需函数函数名
export default function() {
  console.log('default')
}

// 引入外部模块,重新导出
export { str as hello } from './b'
typescript
// b.ts

// 导出常量
export const str = 'hello'
typescript
// c.ts

// 批量导入
import { a, b, c } from './a'
// 导入接口
import { P } from './a'
// 导入时起别名
import { f as F } from './a'
// 导入模块的所有成员,绑定到 All 上
import * as All from './a'
// 不加 {},导入默认
import defaultFunction from './a'

console.log('-- es6 module start --')

console.log(a, b, c)

const p: P = {
  x: 1,
  y: 2
}

console.log(F)

console.log(All)

defaultFunction()

console.log('-- es6 module end --')
typescript

CommonJS 模块化

// a.ts

const a = {
  x: 1,
  y: 2
}

// 整体导出
module.exports = a
typescript
// b.ts

// exports === module.exports
// 导出多个变量
exports.c = 3;
exports.d = 4;
typescript
// c.ts

const c1 = require('./a')
const c2 = require('./b')

console.log('-- commonjs module start --')

console.log(c1)
console.log(c2)

console.log('-- commonjs module end --')
typescript

由于 ts 无法直接用 node 执行,我们可以借助 ts-node 来执行 ts 文件。

npx ts-node .\src\node\c.ts
js

总结

ts 对两个模块系统都有比较好的支持。

ts 编译时默认会把所有的模块都编译成 commonjs 模块。ts 会对 es6 写法进行特殊处理。

tsc .\src\es6\c.ts  
js
// a.js

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
exports.__esModule = true;
exports.hello = exports.G = exports.f = exports.c = exports.b = exports.a = void 0;
// 导出
exports.a = 1;
// 批量导出
var b = 2;
exports.b = b;
var c = 3;
exports.c = c;
// 导出函数
function f() { }
exports.f = f;
// 导出时起别名
function g() { }
exports.G = g;
// 默认导出,无需函数函数名
function default_1() {
    console.log('default');
}
exports["default"] = default_1;
// 引入外部模块,重新导出
var b_1 = require("./b");
__createBinding(exports, b_1, "str", "hello");
js
// b.js

"use strict";
exports.__esModule = true;
exports.str = void 0;
// 导出常量
exports.str = 'hello';
js
// c.js

"use strict";
exports.__esModule = true;
// 批量导入
var a_1 = require("./a");
// 导入时起别名
var a_2 = require("./a");
// 导入模块的所有成员,绑定到 All 上
var All = require("./a");
// 不加 {},导入默认
var a_3 = require("./a");
console.log('-- es6 module start --');
console.log(a_1.a, a_1.b, a_1.c);
var p = {
    x: 1,
    y: 2
};
console.log(a_2.f);
console.log(All);
(0, a_3["default"])();
console.log('-- es6 module end --');
js

es 中允许一个模块有一个顶级的导出,也就是 export default ,同时也允许次级导出 export

commonjs 只允许一个模块有一个顶级的导出,module.exports,如果一个模块有次级导出exports.xx,不能再存在顶级导出,否则会覆盖次级导入的多个变量(无论定义前后)。

如果 es6 模块存在顶级导出,而且可能会被 node 模块引用,ts 为我们提供了一个兼容性语法。

// d.ts

export = function () {
  console.log('default export')
}
// 这个语法会被编译成 module.exports, 相当于 commonjs 的默认导出 
// 同时也意味着这个模块不能再有其他的导出(不能在具有其他导出元素的模块中使用导出分配)
// 如果想导出其他变量,可以合并为一个对象中进行导出
ts

导入时可以使用 es6 默认导入,也可以使用特殊语法导入。结果是一样的。

tsconfig.json - esMod uleInterop: “true” 开启时可以支持上述两种方法导入。如果关闭,只能通过 import c4 = 的方式导入。

// import c4 = require('../es6/d') 特殊
import c4 from '../es6/d'

c4();
ts

使用命名空间

JavaScript 中命名空间可以有效避免全局污染。ES6 引入模块系统之后,命名空间就很少被提及,不过 TS 仍然实现了这个特性。

尽管在模块系统中我们不必考虑全局污染问题,但是如果要使用全局的类库,命名空间仍然是一个比较好的解决方案。

// a.ts

namespace Shape {
  const pi = Math.PI
  export function circle(r: number) {
    return pi * r * 2
  }
}
typescript

随着程序拓展,命名空间文件会越来越大,命名空间也可以进行拆分。

// b.ts

namespace Shape {
  export function square(x: number) {
    return x * x
  }
}
typescript

当多文件使用同一个名称的命名空间时,它们之间可以共享。

如果使用命名空间:

// b.ts

namespace Shape {
  export function square(x: number) {
    return x * x
  }
}


Shape.circle(1)
Shape.square(1)
typescript

使用命名空间我们需要明确一个原则,命名空间和模块不要混用,不要在一个模块中使用命名空间。命名空间最好在全局环境下使用。

我们可以将 ts 文件编译成 js,然后在 index.html 中通过 script 标签引用。

tsc .\src\name-space\a.ts .\src\name-space\b.ts  --outDir public
shell
// a.js
var Shape;
(function (Shape) {
    var pi = Math.PI;
    function circle(r) {
        return pi * r * 2;
    }
    Shape.circle = circle;
})(Shape || (Shape = {}));

// b.js
var Shape;
(function (Shape) {
    function square(x) {
        return x * x;
    }
    Shape.square = square;
})(Shape || (Shape = {}));
Shape.circle(1);
Shape.square(1);
js
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ts-base</title>
</head>
<body>
  <div class="app"></div>

  <script src="a.js"></script>
  <script src="b.js"></script>
</body>
</html>
html

我们现在在使用命名空间成员时都加了前缀 Shapre ,有的时候为了简便,我们可以给函数起一个别名。

// b.ts

namespace Shape {
  export function square(x: number) {
    return x * x
  }
}

console.log('-- name space start --')

console.log(Shape.circle(1))
console.log(Shape.square(1))

import cicle = Shape.circle // 与模块中的 import 没有任何关系,然后我们就可以直接执行 SQL 函数

console.log(cicle(3))

console.log('-- name space end --')
ts

ts 早期版本中,命名空间也叫内部模块,本质上就是一个闭包,可以用于隔离作用域。
ts 保留命名空间,更多考虑是对全局变量时代的兼容。目前,在一个模块化系统中,我们其实不必使用命名空间。

理解声明合并

声明合并指编译器会把程序中多个地方具有相同名称的声明合并为一个声明。
通过声明合并就可以避免对接口成员的遗漏。

{
  // 非函数成员
  interface A {
    x: number
    // y: string 
    // Type 'number' is not assignable to type 'string'.
    // 相同属性必须类型一致
  }
  interface A {
    y: number
  }
  const a: A = {
    x: 1,
    y: 1 
  }

  // 函数成员
  // 每一个函数都会被声明为一个函数重载
  interface B {
    foo (bar: number): number // 5
    foo (bar: 'b1'): number // 2
  }
  interface B {
    foo (bar: string): string // 3
    foo (bar: number[]): number[] // 4
    foo (bar: 'b2'): number // 1 
  }
  const b = {
    foo(bar: any): any {
      return bar
    }
  }
  // 函数重载的时候需要注意函数重载的顺序,因为编译器会按顺序进行匹配
  // 那么接口合并如何确认顺序?
  // 接口内部按书写顺序来确定,接口之间后面的接口会排在前面(顺序排名已标在代码后)
  // 如果函数参数是字符串字面量,那么这个声明就会被提升到整个函数声明的最顶端
  // 上述规则就是接口之前的声明合并

  // 命名空间合并
  // 命名空间中导出的成员是不可以重复定义的
}

// 命名空间与函数的合并
// 在 JS 中创建一个函数,给他增加一些属性是很常见的一个模式
// 通过命名空间和函数的声明也可以实现这个模式
function Lib() {}
namespace Lib {
  export let version = '1.0'
}
console.log(Lib)
console.log(Lib.version)

// 命名空间与类的声明合并
// 以下代码相当于给类添加了一些静态属性
class C {}
namespace C {
  export let state = 1
}
console.log(C)
console.log(C.state)

// 命令空间与枚举合并
// 以下代码相当于给枚举增加了一个方法
enum Color {
  Red,
  Yellow,
  Bule
}
namespace Color {
  export function mix() {}
}
console.log(Color)
// {0: 'Red', 1: 'Yellow', 2: 'Bule', Red: 0, Yellow: 1, Bule: 2, mix: ƒ}

// ★ 命名空间与函数/类时一定要放到函数/类定义之后,否则就会报错
// 枚举和命名空间的定义位置无关,没有要求
// 从编译结果来看,class 是重复声明,function 是内容覆盖,枚举则是创建对象并添加属性,
// 命名空间也是创建对象并添加属性,所以枚举可以在命名空间后面,因为它们本质上都是看全局是否存在同名对象,
// 如果有直接添加属性,如果没有则创建并添加属性

// 在我们的程序中如果存在多处同名的声明,并不是一个好的做法,最好还是将它们封装到一个模块中
// ts 拥有这种特性,只是为了兼容旧的开发模式,这使得 ts 可以与老的代码共存,并且还可以帮助发现一些设计缺陷
typescript

为什么命名空间与函数/类时一定要放到函数/类定义之后,而枚举则不需要?

从编译结果来看,class 是重复声明产生隔报错,function 会发生内容覆盖,枚举则是创建对象并添加属性,
命名空间也是创建对象并添加属性,所以枚举可以在命名空间后面。因为它们本质上都是看全局是否存在同名对象,如果有直接添加属性,如果没有则创建并添加属性。

function Lib() {}
namespace Lib {
    export const version = '0.1'
}
// =>
function Lib() { }
(function (Lib) {
    Lib.version = '0.1';
})(Lib || (Lib = {}));

enum Color {
    RED,
    BLUE,
}
namespace Color {
    export const version = '0.1'
}
// =>
var Color;
(function (Color) {
    Color[Color["RED"] = 0] = "RED";
    Color[Color["BLUE"] = 1] = "BLUE";
})(Color || (Color = {}));
(function (Color) {
    Color.version = '0.1';
})(Color || (Color = {}));
js

编写声明文件

本小节我们来学习如何在 ts 中引入 web 类库以及如何为它们编写声明文件。以 jQuery 为例。

pnpm install jquery
shell

类库一般分为三类,分别是全局类库、模块类库及 UMD 类库。jQuery 是一种 UMD 类库(既可以通过全局方式使用,也可以通过模块化的方式引用)。为了方便,我们采用模块化的方式来使用。

// src/libs/index.ts

import $ from 'jquery' // Could not find a declaration file for module 'jquery'.
typescript

直接引用 ts 会报错,无法找到 jqeury 的声明文件。这是因为 jQuery 是用 javascript 编写的,我们在使用非 ts 的类库时,必须为这个类库编写一个声明文件。对外暴露它的 API。有些类库的声明文件是包含在源码中的,有些是单独提供的,需要额外安装。

大多数类库社区都提供了声明文件,使用的方法就是需要额外安装类型声明包。包名通常是 @types/jquey

pnpm i @types/jquery -D
shell

声明文件安装完毕后,ts 代码就不会报错了。接下来我们就可以在 ts 中使用 jQuery 了。

// src/libs/index.ts

import $ from 'jquery' // Could not find a declaration file for module 'jquery'.

$('.app').css('color', 'red')
typescript

我们在 ts 中使用 web 类库时,首先就要考虑它是否存在声明文件,你可以通过 这个地址 进行查询。

如果社区没有声明文件,就需要你自己编写声明文件。这也是你贡献社区的好机会。

下面我们来学习三种类库的声明文件写法。

首先我们在 public 目录下新建 gloabl-lib.js,并在 index.html 引用 global-lib.js

全局库声明文件

// global-lib.js

function globalLib(options) {
  console.log(options)
}

globalLib.version = '1.0.0'

globalLib.doSomething = function() {
  console.log('globalLib do something')
}

// 这是一个非常典型的全局类型的使用方法
js
// src/libs/index.ts

globalLib({ x: 1 }) // Cannot find name 'globalLib'.
typescript

直接使用全局模块同样存在找不到 globalLib 类库的问题。下面我们来定义声明文件。

// public/global-lib.js
function globalLib(options) {
  console.log(options)
}

globalLib.version = '1.0.0'

globalLib.doSomething = function() {
  console.log('globalLib do something')
}

// src/libs/global-lib.d.ts
declare function globalLib(options: globalLib.Options): void

declare namespace globalLib {
  const version: string

  function doSomething(): void

  // interface 接口也可以放到全局,但是会对全局暴露,建议放到命名空间中
  interface Options {
    [key: string]: any
  }
}
typescript

我们使用 declare 关键字,它可以用外部变量提供类型声明。我们使用函数与 namespace 的声明合并,为函数增加属性。

我们还可以调用 globalLib 的内部方法,都可以正常使用。

// src/libs/index.ts

globalLib.doSomething()
typescript

模块库声明文件

// src/libs/module-lib.js

const version = '1.0.0'

function doSomething() {
  console.log('moduleLib do something')
}

function moduleLib(options) {
  console.log(options)
}

moduleLib.version = version
moduleLib.doSomething = doSomething

module.exports = moduleLib
js
// src/libs/module-lib.d.ts

declare function moduleLib(options: Options): void

interface Options {
  [key: string]: any
}

declare namespace moduleLib {
  // export const version: string // export 关键字加或不加都可以
  const version: string
  function doSomething(): void
}

export = moduleLib // 这种写法导出兼容性比较好
typescript
// src/libs/index.ts

import moduleLib from './module-lib'

moduleLib.doSomething()
typescript

UMD 库声明文件

// src/libs/umd-lib.js

(function (root, factory){
  if (typeof define === 'function' && define.umd) {
    define(factory)
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory()
  } else {
    root.umdLib = factory()
  }
}(this, function() {
  return {
    version: '1.0.0',
    doSomething() {
      console.log('umdLib do something')
    }
  }
}))
js
// src/libs/umd-lib.d.ts

declare namespace umdLib {
  const version: string
  function doSomething(): void
}

export as namespace umdLib // 专门为 umd 类库设置的语句
export = umdLib
typescript
// src/libs/index.ts

import umdLib from './umd-lib'
umdLib.doSomething()
typescript

umd 库不仅可以在模块中使用,还可以在全局中引用,和 globalLib 一样。

我们注释掉模块中导入,并在全局引入它。

// import umdLib from './umd-lib'
umdLib.doSomething()
//  TS2686: 'umdLib' refers to a UMD global, but the current file is a module. Consider adding an import instead.
typescript

注释掉代码,会提示 umdLib 指全局,但当前文件是模块,考虑为其添加导入。如果我们不想看见此报错,可以配置 tsconfig.json 文件的 allowUmdGlobalAccess 属性为 true。这样就不会报错了。

全局引入 umdLib 和使用 globalLib 方式一致。

模块插件/全局插件

有时候我们想给一些类库添加一些自定义方法,这时就需要用到插件。

比如我们想给 moment 类库添加一个时间方法。

pnpm i moment
shell

当我们给 moment 增加一个方法,这时会提示没有这个方法。

import moment from 'moment'
moment.myFunction = () => {}

//  TS2339: Property 'myFunction' does not exist on type 'typeof moment'.  
typescript

我们可以使用 declare 处理这个问题。

declare module 'moment' {
  export function myFunction(): void
}
typescript

这样我们就可以给一个外部类库增加一个自定义方法。这就是模块化的插件。

我们还可以给一个全局变量添加一个方法。比如我们给 globalLib 增加一个自定义方法。

declare global {
  namespace globalLib {
    function doAnything(): void
  }
}
globalLib.doAnything = () => {}
typescript

不过这样会对全局的命名空间造成污染,一般不建议这样做。

声明文件依赖

如果一个类库比较大,它的声明文件会很长,一般会按照模块划分。那么这些声明文件就会存在一定的依赖关系。

我们可以来看一下 jQuery 的声明文件是如何组织的。

jquery.png

// package.json  types 字段代表声明文件入口

{
	"types": "index.d.ts",
}
json
// index.d.ts
	
// 贡献者列表
// Type definitions for jquery 3.5
// Project: https://jquery.com
// Definitions by: Leonard Thieu <https://github.com/leonard-thieu>
//                 Boris Yankov <https://github.com/borisyankov>
//                 Christian Hoffmeister <https://github.com/choffmeister>
//                 Steve Fenton <https://github.com/Steve-Fenton>
//                 Diullei Gomes <https://github.com/Diullei>
//                 Tass Iliopoulos <https://github.com/tasoili>
//                 Sean Hill <https://github.com/seanski>
//                 Guus Goossens <https://github.com/Guuz>
//                 Kelly Summerlin <https://github.com/ksummerlin>
//                 Basarat Ali Syed <https://github.com/basarat>
//                 Nicholas Wolverson <https://github.com/nwolverson>
//                 Derek Cicerone <https://github.com/derekcicerone>
//                 Andrew Gaspar <https://github.com/AndrewGaspar>
//                 Seikichi Kondo <https://github.com/seikichi>
//                 Benjamin Jackman <https://github.com/benjaminjackman>
//                 Josh Strobl <https://github.com/JoshStrobl>
//                 John Reilly <https://github.com/johnnyreilly>
//                 Dick van den Brink <https://github.com/DickvdBrink>
//                 Thomas Schulz <https://github.com/King2500>
//                 Terry Mun <https://github.com/terrymun>
//                 Martin Badin <https://github.com/martin-badin>
//                 Chris Frewin <https://github.com/princefishthrower>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// TypeScript Version: 2.7

// 依赖文件
/// <reference types="sizzle" />
/// <reference path="JQueryStatic.d.ts" />
/// <reference path="JQuery.d.ts" />
/// <reference path="misc.d.ts" />
/// <reference path="legacy.d.ts" />

export = jQuery;
typescript

上面的依赖文件分为两种,分别是模块依赖和路径依赖。

模块依赖使用 types 属性,比如文中的 types="sizzle" ,sizzle 是 jQuery 的一个引擎。ts 会在 @types 目录下寻找这个模块。然后把对应的定义引入进来。

sizzle.png

路径依赖使用 path 属性。代表一个相对路径。使用 index.d.ts 同级的一些文件。

总结

如果你觉得编写一个声明文件很困难,或者官方得例子很难看懂,一个比较好得方法就是去看一些知名类库得声明文件是如何编写得。从这个过程中你会受到很大得启发。

用.d.ts为后缀,ts就能感知这是申明文件并在整个工程下做类型检查?一般项目中这些申明文件编写一般是存放在哪,或者是怎么管理的?

  • 如果开发的是类库,声明文件应该放在 package.json 指定的 “types” 路径下,位置随意;或者在包的根目录下同时放置 index.js 和 index.d.ts,就不需要使用 “types” 指定了;也可单独发布声明文件包 @types/xxx;
  • 如果是普通的项目工程,除非 js 和 ts 混写,且 ts 引用了 js 模块,一般不需要写声明文件,这种情况下,需要把声明文件和源文件放在一起,如 lib.js、lib.d.ts

为什么说 export = 的兼容性是最好的呢?

这种导出语法可以兼容ES6模块、CommonJS模块的导入。

如何确定一个类库是全局库、模块库、还是UMD库中的哪种呢?

全局库对外保留全局变量,模块库有export 语句,UMD库有典型的UMD封装。