likes
comments
collection
share

这一次,彻底掌握TypeScript(九)工程化实践

作者站长头像
站长
· 阅读数 12

这是我彻底掌握 TypeScript 的第九篇,这次我们主要分享 TypeScript 中的工程化实践。

点击下面链接可以查看之前文章。

往期文章:

这一次,彻底掌握TypeScript(一)基本类型&语法

这一次,彻底掌握TypeScript(二)接口与类

这一次,彻底掌握TypeScript(三)断言与类型别名

这一次,彻底掌握TypeScript(四)函数

这一次,彻底掌握TypeScript(五)泛型

这一次,彻底掌握TypeScript(六)装饰器及相关应

这一次,彻底掌握TypeScript(七)类型检查机制

# 这一次,彻底掌握TypeScript(八)高级类型

TypeScript 作为 JavaScript 的超集,由于其静态类型系统的引入,使得前端开发大型项目更容易实现团队内代码规范限制及接口数据约定,同时使得代码更容易阅读和理解,但是当我们在基于 TypeScript 实现大型项目的过程中同样要注意如下内容:

声明空间

在 TypeScript 里存在两种声明空间:类型声明空间与变量声明空间:

一、类型声明空间

类型声明空间包含用来当做类型注解的内容,例如下面的类型声明:

class Foo {}
interface Bar {}
type Bas = {};

你可以将 Foo, Bar, Bas 作为类型注解使用,示例如下:

let foo: Foo;
let bar: Bar;
let bas: Bas;

注意,尽管你定义了 interface Bar,却并不能够把它作为一个变量来使用,因为它没有定义在变量声明空间中。

interface Bar {}
const bar = Bar; // Error: "cannot find name 'Bar'"

出现错误提示提示: cannot find name 'Bar' 的原因是名称 Bar 并未定义在变量声明空间。这将带领我们进入下一个主题 -- 变量声明空间。

二、变量声明空间

变量声明空间包含可用作变量的内容,在上文中 Class Foo 提供了一个类型 Foo 到类型声明空间,此外它同样提供了一个变量 Foo 到变量声明空间,如下所示:

class Foo {}
const someVar = Foo;
const someOtherVar = 123;

因此当我们想把一个类来当做变量传递时这显得极为便捷,但是我们并不能把一些如 interface 定义的内容当作变量使用。与此相似,一些用 var 声明的变量,也只能在变量声明空间使用,不能用作类型注解。举个🌰:

const foo = 123;
let bar: foo; // ERROR: "cannot find name 'foo'"

提示 ERROR: "cannot find name 'foo'" 原因是,名称 foo 没有定义在类型声明空间里。

模块系统

一、全局模块

在默认情况下,如果一个目录下存在一个 tsconfig.json 文件,那么它意味着这个目录是当前 TypeScript 项目的根目录。默认情况下,你在当前项目目录下任何一处编写的 TS 代码总是对于根目录起的所有子目录可见(不论是变量声明空间亦或是类型声明空间都可见),当你开始在一个新的 TypeScript 文件中写下代码时,它处于全局命名空间中。如在 foo.ts 里的以下代码。

const foo = 123;

如果你在相同的项目里创建了一个新的文件 bar.ts,TypeScript 类型系统将会允许你使用变量 foo,就好像它在全局可用一样:

const bar = foo; // allowed

毋庸置疑,使用全局变量空间是危险的,因为它会与文件内的代码命名冲突。我们推荐使用下文中将要提到的文件模块。

二、文件模块

文件模块也被称为外部模块。如果在你的 TypeScript 文件的根级别位置含有 import 或者 export,那么它会在这个文件中创建一个本地的作用域。因此,我们需要把上文 foo.ts 改成如下方式(注意 export 用法):

export const foo = 123;

在全局命名空间里,我们不再有 foo,这可以通过创建一个新文件 bar.ts 来证明:

const bar = foo; // ERROR: "cannot find name 'foo'"

如果你想在 bar.ts 里使用来自 foo.ts 的内容,你必须显式地导入它,更新后的 bar.ts 如下所示。

import { foo } from './foo';
const bar = foo; // allow

在 bar.ts 文件里使用 import 时,它不仅允许你使用从其他文件导入的内容,还会将此文件 bar.ts 标记为一个模块,文件内定义的声明也不会“污染”全局命名空间。 文件模块拥有强大的功能和较强的可用性,我们可以通过指定 tsconfig.json 中的 module 属性指定不同的模块系统,较为常用的是 ES Module 以及 Commonjs 模块:

1、ES Module

(1)单独导出一个变量或类型

export const someVar = 123;
export type someType = {
  foo: string;
};

(2)批量导出

const someVar = 123;
type someType = {
  type: string;
};

export { someVar, someType };

(3)导出时重命名

const someVar = 123;
export { someVar as aDifferentName };

(4)默认导出

ES 模块中导出主要通过 export default 实现,主要有如下三种应用场景:

  • 在一个变量之前(不需要使用 let/const/var);
  • 在一个函数之前(不需要函数名);
  • 在一个类之前。
// some var
export default (someVar = 123);

// some function
export default function someFunction() {}

// some class
export default class someClass {}

(5)导入一个变量或者是一个类型

import { someVar, someType } from './foo';

(6)通过重命名的方式导入变量或者类型

import { someVar as aDifferentName } from './foo';

(7)整体导入

import * as foo from './foo';
// 你可以使用 `foo.someVar` 和 `foo.someType` 以及其他任何从 `foo` 导出的变量或者类型

(8)只导入模块

import 'core-js'; // 一个普通的 polyfill 库

(9)从其他模块导入后整体导出

export * from './foo';

(10)从其他模块导入后,部分导出

export { someVar } from './foo';

(11)通过重命名,部分导出从另一个模块导入的项目

export { someVar as aDifferentName } from './foo';

(12)默认导入

import someLocalNameForThisFile from './foo';

2、CommonJS

(1)部分导出

class someClass {};
exports.someClass = someClass

(2)部分导入

const test = require("./foo").someClass;

(3)整体导出

class Class1 {};
class Class2 {}
module.exports = { Class1, Class2 }

(4)整体导入

const test = require("./foo")

在 CommonJS 中,整体导出就是一个 module.exports ,exports 实际指向的也是 module.exports 指向的引用,而 require 方法实际上返回的是 module.exports 而不是 exports,因此如果对 exports 重新赋值,则断开了 exports 对 module.exports 的指向。

3、CommonJS与ES Module之间的区别

  • CommonJS 是被加载的时候运行,ES Module 是编译的时候运行
  • CommonJS 输出的是值的浅拷贝,ES Module 输出值的引用
  • CommonJS 具有缓存。在第一次被加载时,会完整运行整个文件并输出一个对象,拷贝(浅拷贝)在内存中。下次加载文件时,直接从内存中取值

CommonJS输出值拷贝

// a.js
let count = 0
exports.count = count; // 输出值的拷贝
exports.add = ()=>{
  //这里改变count值,并不会将module.exports对象的count属性值改变
  count++;
}

// b.js
const { count, add } = require('./a.js')
console.log(count) // 0
add();
console.log(count) // 0

ES Module输出值引用

// a.js
export let count = 0; // 输出的是值的引用,指向同一块内存
export const add = ()=>{
  count++; // 此时引用指向的内存值发生改变
}


// b.js
import { count, add } from './a.js'
console.log(count) // 0
add();
console.log(count) // 1

CommonJS输出浅拷贝验证

// a.js
const foo = {
	count: 0
}
// module.exports的foo属性为foo对象的浅拷贝,指向同一个内存中
exports.foo=foo;

window.setTimeout(()=>{
	foo.count += 1
	console.log('changed foo')
}, 1000)

// b.js
const { foo } = require('./a.js')
console.log('foo', foo); // 'foo', {count: 0}
window.setTimeout(()=>{
  console.log('after 2s foo', foo); // 'after 2s foo ', {count: 1}
}, 2000)

其实上个🌰中的 const { foo } = require('./a.js') 或者 const foo = require('./a.js').foo 写法是相当危险的。因为 CommonJS 输出的值的拷贝,若后面在 a.js 中对 foo 的内存指向作出改动,则不能及时更新。举个🌰:

// a.js
const foo = {
	count: 0
}
exports.foo = foo; // 此时 foo 指向 {count: 0} 的内存地址
window.setTimeout(()=>{
  //改变 foo 的内存指向
	exports.foo = 'haha';
},1000)

// b.js
const  { foo }  = require('./a.js'); // 拷贝了 foo 属性指向 {count: 0} 内存地址的引用
console.log('foo', foo); // 'foo',{count: 0}
window.setTimeout(()=>{
  //此处并没有改变
  console.log('after 2s foo', foo); // 'after 2s foo ',{count: 0}
}, 2000)

改进:

// b.js
const test = require('./a.js'); 
// test 拷贝了 整个输出对象{foo:{count: 0}}内存地址的引用
// 当内存中的属性值发生变化时,可以拿到最新的值,因为指向的是同一片内存

console.log('foo', test.foo); //'foo',{count: 0}
window.setTimeout(()=>{
  // 保证获取到的是最新的
  console.log('after 2s foo', test.foo); // 'after 2s foo ','haha'
}, 2000)

进阶:

// a.js
let foo = 1
setTimeout(()=>{
  foo = 2;
  exports.foo = foo
},1000)
exports.foo = foo

// b.js
var test = require('./a.js');
console.log(test.foo); // 1
setTimeout(()=>{
  console.log(test.foo); // 2
},2000)

但是需要注意由于 module.exports 实际上输出的是内存地址的引用,所以如果缓存的内存地址依然存在且无变更的话依然是之前的值,举个🌰:

// a.js
let foo = 1
setTimeout(()=>{
  foo = 2;
  module.exports = { foo }; // 注意:指向新内存 { foo:2 }
},1000)
module.exports = { foo }; // 指向内存 { foo:1 }

// b.js
var test = require('./a.js'); // 浅拷贝,指向的还是 { foo:1 } 的内存,并缓存在内存中
//从缓存的内存中取值
console.log(test.foo); // 1

setTimeout(()=>{
  // 从缓存的内存取值
  console.log(test.foo) // 1
},2000)

4、兼容性处理

我们在 tsconfig.json 中通过配置 target 和 module 属性可以实现不同版本、不同模块化方案代码的编译生成,需要注意的是如果 target 属性设为 ES3 或 ES5 时及时 module 指定为 es2015 也依然会编译成 CommonJS 模块,举个🌰:

export const a: number = 1223;

export function getA() {
  return a;
}

const b: string = 'b';
const c: string = 'c';
export { b, c }

export default function () {
  console.log('6666');
}

编译后(target: "es5", module: "es2015"):

"use strict";
exports.__esModule = true;
exports.c = exports.b = exports.getA = exports.a = void 0;

exports.a = 1223;

function getA() {
    return exports.a;
}
exports.getA = getA;

var b = 'b';
exports.b = b;
var c = 'c';
exports.c = c;

function default_1() {
    console.log('6666');
}
exports["default"] = default_1;

CommonJS 本身不支持默认导出,所以 CommonJS 中转换 ES Module 默认导出的方案是给 exports 添加 default 属性, 因此默认导入也会相应的发生变更:

import * as All from './action';
console.log(All);

import { a } from './action';
console.log(a);

import { getA as get } from './action';
console.log(get);

import defaultFuc from './action';
console.log(defaultFuc);

编译后:

"use strict";
exports.__esModule = true;

var All = require("./action");
console.log(All); // {__esModule: true, a: 1223, getA: [Function: getA], b: 'b', c: 'c', default: [Function: default_1]}

var action_1 = require("./action");
console.log(action_1.a); // 1223

var action_2 = require("./action");
console.log(action_2.getA); // [Function: getA]

var action_3 = require("./action");
console.log(action_3["default"]); // [Function: default_1]

因此 ES Module 中的默认导出向 CommonJS 转换对于我们开发者来说其实是无感知的,因为它的引入的时候会自动获取导出模块的 default 属性,因此我们可以在 CommonJS 模块中通过获取导出对象的 default 属性获取默认导出,举个🌰:

// a.ts
export default function () {
  console.log('default');
}
// b.ts
const a = require('./a');
a.default(); // "default"

但是 CommonJS 模块中通过 default 属性调用显得有些格格不入,而且很容易产生错误,那么如何解决两个模块系统之间的不兼容性问题呢?TypeScript 为了解决兼容性问题给我们提供了一些其他语法糖,举个🌰:

// a.ts
export = function () {
  console.log('I am module A');
}
// b.ts
import a = require('./a');
// import a from './a';
a(); // "I am module A"

export = *** 语法本身会被编译成 CommonJS 模块中的 module.exports 语法糖,所以在文件 a.ts 中不能再导入其他模块,否则会编译报错。在 b.ts 文件中我们可以通过 TypeScript 提供的 import moduleName = require('***') 语法导入,需要注意当 tsconfig.json 中的 esModuleInterop 属性如果为 true,这儿也可以通过 ES6 提供的普通导入方法导入,如果为 false 则只能通过 import moduleName = require('***') 语法导入。

命名空间

在 JavaScript 中命名空间能有效的避免全局污染,但是在 ES6 引入模块系统之后命名空间就很少被提及了,但是 TypeScript 中依然实现了这个特性,尽管在模块化系统中我们完全不用考虑命名空间了,但是如果使用了一些全局的类库,命名空间仍然是一个比较好的解决方案。

一、定义

TypeScript 中命名空间可以通过关键字 namespace 声明,命名空间内可以定义任意多变量,这些变量仅在命名空间内可见,如果想要部分成员在全局模块内可见的话则需要使用 export 关键字将其导出,举个🌰:

// a.ts
namespace Shape {
  const pi = Math.PI;
  export function circle(r: number) {
    return pi * r ** 2 
  }
}
// b.ts
Shape.circle(2); // 12.566370614359172

随着我们程序的不断扩张这个命名空间也可能会随着不断扩大,所以需要将命名空间进行进一步的拆分,TypeScript中将命名空间进行拆分极为简单,举个🌰:

// a.ts
namespace Shape {
  const pi = Math.PI;
  export function circle(r: number) {
    return pi * r ** 2 
  }
}
// b.ts
/// <reference path="a.ts" />
// a.ts的相对路径
namespace Shape {
  export function square(x: number) {
    return x ** 2
  }
}

Shape.circle(2);
Shape.square(2);

在文件 a.ts 和 b.ts 中都通过 namespace 关键字命名了同名的命名空间,那么这个命名空间就分布在了这两个文件中,它们之间是共享一个命名空间的,此处需要注意由于在 b.ts 文件中调用了 a.ts 文件中声明的 circle 方法,所以需要在 b.ts 文件中通过三斜线指令 /// <reference path="a.ts" /> 来调用 a.ts 文件,此时我们编译成 JavaScript 代码并查看:

// a.js
var Shape;
(function (Shape) {
    var pi = Math.PI;
    function circle(r) {
        return pi * Math.pow(r, 2);
    }
    Shape.circle = circle;
})(Shape || (Shape = {}));

// b.js
/// <reference path="a.ts" />
var Shape;
(function (Shape) {
    function square(x) {
        return Math.pow(x, 2);
    }
    Shape.square = square;
})(Shape || (Shape = {}));
Shape.square(2);
Shape.circle(2);

从编译后的代码中我们可以看到 TypeScript 中的命名空间本质上依然是通过 JavaScript 的立即执行匿名函数实现的,这在 JavaScript 确保创建的变量不会泄漏至全局命名空间时是极为常见的一种做法,接下来我们一起看一下 TypeScript 命名空间成员的别名。

二、成员别名

在上述示例中我们每次通过 Shape.circle 或 Shape.square 方法调用难免有些繁杂累赘,TypeScript 为我们提供了更为简便的调用,举个🌰:

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

import square = Shape.square;
square(2);

需要注意这里的 import 与模块系统中的 import 没有任何关系,通过这种方法将命名空间中的部分成员导入后我们就可以直接调用了,由于我们在导入的过程中还可以为导入变量或函数重命名,因此这种命名空间中导出变量或函数的导入方法的使用更为普遍与广泛。

三、命名空间与函数的合并

function lib () {}

namespace lib {
  export let version = '0.1.0'
}

在上述示例中我们通过命名空间与同名函数的合并实现了向函数 lib 中添加属性 version,这在 JavaScript 中是极为常见的,TypeScript 为我们提供了这种更为优雅的实现,我们查看其编译后的 JavaScript 代码是怎样的?

"use strict";
function lib() { }
(function (lib) {
    lib.version = '0.1.0';
})(lib || (lib = {}));

与之类似命名空间还可以与类合并为其添加静态属性,与枚举类型合并为其添加方法,举个🌰:

class C {}
namespace C {
  export let version = '0.1.0'
}
console.log(C.version) // '0.1.0'

enum Color {
  Red,
  Yellow
}
namespace Color {
  export function mix() {}
}
console.log(Color.mix) // function mix() {} 

此处需要注意命名空间与类或与函数合并时,函数或类的定义必须在命名空间前面,否则会报错,而枚举类型与命名空间的定义之间的位置先后是没有要求的,

声明合并

TypeScript 中的声明合并实际上就是指编译器会把程序中的多个地方具有相同名称的多个声明合并为一个声明,这样做的好处在于可以把程序中散落各处的声明合并在一起,进而避免对程序声明的遗漏,举个🌰:

interface A {
  x: number;
}
interface A {
  y: number;
}

const a: A = {
  x: 12,
  y: 14
}

TypeScript 要求声明合并中非函数成员必须具有唯一性,即最好没有相同的属性名,如果两处声明具有相同的属性则必须类型一致,否则编译器会报错,举个🌰:

interface A {
  x: number;
  y: string | number;
}
interface A {
  // Property 'y' must be of type 'string | number', but here has type 'number'.
  y: number;
}

那么函数成员会怎么处理呢?实际上 TypeScript 会将每个函数看作函数重载,举个🌰:

interface A {
  x: number;
  func(x: string): string;
}
interface A {
  y: number;
  func(x: number): number;
  func(x: number[]): number[];
}

const a: A = {
  x: 12,
  y: 14,
  func(x: any) {
    return x
  }
}

那么声明合并的时候函数重载列表的顺序是如何确定的呢?实际上这里主要会遵循两个原则:

  1. 接口内部按照书写先后顺序确定
  2. 不同接口间后面的会排在前面

因此上述示例中 func 属性的排列顺序如下所示:

interface A {
  x: number;
  func(x: string): string; // 3
}
interface A {
  y: number;
  func(x: number): number; // 1
  func(x: number[]): number[]; // 2
}

不过上面的原则也有一个例外,如果函数的参数为字符串的字面量的话那么这个声明就会被提升到整个函数声明的最顶端,举个🌰:

interface A {
  x: number;
  func(x: string): string; // 5
  func(x: 'b'): string; // 2
}
interface A {
  y: number;
  func(x: number): number; // 3
  func(x: number[]): number[]; // 4
  func(x: 'a'): string; // 1
}

声明文件

一、声明语法

1、全局变量

使用 declare var 声明变量。 如果变量是只读的,那么可以使用 declare const。 你还可以使用 declare let 变量拥有块级作用域。

代码:

console.log("Half the number of widgets is " + (foo / 2))

声明:

declare var foo: number

一般来说,全局变量都是禁止修改的常量,所以大部分情况都应该使用 const 而不是 var 或 let。

2、全局函数

使用 declare function 声明函数

代码:

greet("hello, world")

声明:

declare function greet(greeting: string): void

在声明语句中,支持函数重载

declare function greet(greeting: string): void
declare function greet(greeting: number): void

3、带属性的对象

使用 declare namespace 描述用点表示法访问的类型或值

代码:

let result = myLib.makeGreeting("hello, world")
console.log("The computed greeting is:" + result)

let count = myLib.numberOfGreetings

声明:

declare namespace myLib {
    function makeGreeting(s: string): string
    let numberOfGreetings: number
}

4、可重用类型接口或类型

除了全局变量之外,可能有一些类型我们也希望能暴露出来。在类型声明文件中,我们可以直接使用 interface 或 type 来声明一个全局的接口或类型。

代码:

greet({
  greeting: "hello world",
  duration: 4000
})

声明:

interface GreetingSettings {
  greeting: string
  duration?: number
  color?: string
}

declare function greet(setting: GreetingSettings): void

type 与 interface 类似.

5、组织类型

代码:

const g = new Greeter("Hello")
g.log({ verbose: true })
g.alert({ modal: false, title: "Current Greeting" })

使用命名空间组织类型

declare namespace Greeter {
  interface LogOptions {
    verbose?: boolean
  }
  interface AlertOptions {
    modal: boolean
    title?: string
    color?: string
  }
}

也可以在一个声明中创建嵌套的命名空间

declare namespace Greeter.Options {
  // Refer to via Greeter.Options.Log
  interface Log {
    verbose?: boolean
  }
  interface Alert {
    modal: boolean
    title?: string
    color?: string
  }
}

6、全局类

使用 declare class 描述一个类或像类一样的对象。 类可以有属性和方法,就和构造函数一样,但不能用来定义具体的实现。

代码:

const myGreeter = new Greeter("hello, world")
myGreeter.greeting = "howdy"
myGreeter.showGreeting()

class SpecialGreeter extends Greeter {
  constructor() {
    super("Very special greetings")
  }
}

声明:

declare class Greeter {
  constructor(greeting: string)
  greeting: string
  showGreeting(): void
}

二、声明文件

在开发过程中不可避免的要引用其他第三方 JavaScript 库,但是在 Typescript 项目中直接引用 JavaScript 库无法通过 TypeScript 的严格类型检查机制,需要我们为其类型声明,即引入声明文件,举个🌰:

import $ from 'jquery'

// Error: Try `npm install @types/jquery` if it exists or add a new declaration (.d.ts) file containing `declare module 'jquery';`

这儿我们通过模块化方式引用 jquery 库,但是由于其是一个 JavaScript 库,所以 TypeScript 的类型检查机制提示类型声明文件不存在,jquery 库的声明文件是单独提供的,我们这儿安装一下就好:

npm install @types/jquery -D

安装完之后,就可以正常在 ts 文件中使用 jquery 了。但是部分类库没有提供声明文件的话就需要我们自己编写声明文件,那么声明文件该如何编写呢?

1、编写声明文件

我们平时引用的第三方库主要由三种类型构成:

  • 全局类库
  • 模块类库
  • UMD类库

全局类库是指能在全局命名空间下访问,不需要 import、require等指令导入,主要在 HTML 中通过 <script> 标签引入;而模块类库只能工作在模块加载器的环境下,遵循不同模块化方案的模块类库有着不同的引入和导出方式,而UMD模块既可以作为模块引入又可以作为全局使用。

(1)全局类库

编写 js 文件,如下所示:

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

globalLib.version = '1.0.0'

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

上述代码中,定义了一个函数,为函数添加了两个元素。接下来我们用 script 标签引入该文件,让该函数作用在全局。

我们在 ts 中调用该函数,如下所示:

globalLib({ a: 1 }) // Error: Cannot find name 'globalLib'.

提示未找到该函数。解决办法为它添加一个声明文件,在同级目录下创建一个同名 d.ts 文件,如下所示:

declare function globalLib (options: globalLib.Options): void

declare namespace globalLib {
  const version: string
  function doSomething (): void
  interface Options {
    [key: string]: any
  }
}

定义了一个同名函数和命名空间,在上一篇声明合并中有介绍过函数与命名空间合并,相当于为函数添加了一些默认属性。函数参数定义了一个接口,参数指定为可索引类型,接受任意属性。declare 关键字可以为外部变量提供声明。

(2)模块类库

以下为 CommonJS 模块编写的文件:

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

同样我们将它引入 ts 文件中使用。

import module from './module-lib/index.js'
// Error: Could not find a declaration file for module './module-lib/index.js'. 

提示未找到该模块,同样我们需要为它编写文件声明。

declare function moduleLib (options: Options): void

interface Options {
  [key: string]: any
}

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

export = moduleLib

上述 ts 与刚刚编写的全局类库声明文件大致相同,唯一的区别这里需要 export 输出。

(3)UMD类库

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    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('umd lib do something')
    }
  }
}))

同样我们将它引入 ts 文件中使用,如果没有声明文件也会提示错误,我们直接看 ts 声明文件

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

export as namespace umdLib

export = umdLib

我们声明了一个命名空间,命名空间内有两个成员 version 和 doSomething,分别对应 umd 中的两个成员。这里与其他类库不同的是,多添加了一条语句 export as namespace umdLib,如果为 umd 库声明,这条语句必不可少。

umd 同样可以使用全局方式引用。

2、声明文件放哪儿

在介绍声明文件如何编写之前,列举一下一般声明文件存放的方式。

  1. 目录 src/@types/,在 src 目录新建 @types 目录,在其中编写 .d.ts 声明文件,声明文件会自动被识别,可以在此为一些没有声明文件的模块编写自己的声明文件,实际上在 tsconfig.json 中 include 字段包含的范围内编写 .d.ts,都将被自动识别;
  2. 与被声明的 js 文件同级目录内,创建相同名称的 .d.ts 文件,这样也会被自动识别,前面示例都采用了此方案;
  3. 设置 package.json 中的 typings 属性值,如 ./index.d.ts. 这样系统会识别该地址的声明文件。同样当我们把自己的js库发布到 npm 上时,按照该方法绑定声明文件。
  4. 通过 npm 模块安装,如 @type/react ,它将被存放在 node_modules/@types/ 路径下。

3、如何发布声明文件

如果我们自己实现了一个js库,如何来写声明文件呢?目前有两种方式用来发布声明文件到 npm 上:

  1. 与你的 npm 包同时捆绑在一起;
  2. 发布到 npm 上的 @types organization

(1)npm包含声明文件

在 package.json 中,你的需要指定 npm 包的主 js 文件,那么你还需要指定主声明文件。如下:

{
  "name": "owl-redux",
  "version": "0.0.1",
  "description": "A simple version of redux",
  "main": "dist/owl-redux.js",
  "typings": "index.d.ts"
}

有的 npm 包设置的 types 属性,它和 typings 具有相同意义。

(2)发布到@types

@types 下面的包是从 DefinitelyTyped 里自动发布的,通过 types-publisher 工具。 如果想让你的包发布为@types包,只需要提交一个pull request到 github.com/DefinitelyT…

4、插件

有时候,我们想给一个第三方类库添加一些自定义的方法。以下介绍如何在模块插件或全局插件中添加自定义方法。

(1)模块插件

我们使用 moment 插件,为它添加一个自定义方法。 关键字 declare module。

import m from 'moment'
declare module 'moment' {
  export function myFunction (): void
}
m.myFunction = () => { console.log('I am a Fn') }

(2)全局插件

在上面我们有介绍全局类库,我们为它添加一个自定义方法。关键字 declare global。

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

5、声明文件依赖

在为一个大型项目编写声明文件其实是极为复杂的,那么如何更好的管理这些声明文件呢?我们以 @types/jquery 类型声明库为例,其在 package.json 配置文件中先通过 types 字段指定了类型声明入口文件:

"types": "index.d.ts",

然后在 index.d.ts 文件中依赖了其他类型声明文件:

// index.d.ts
/// <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;

这儿的三斜线指令其本质是包含单个XML标签的单行注释。 注释的内容会做为编译器指令使用,在 TypeScript 中编译器会对输入文件进行预处理来解析所有三斜线引用指令。 在这个过程中,额外的文件会加到编译过程中。这个过程会以一些根文件开始; 它们是在命令行中指定的文件或是在 tsconfig.json中的"files"列表里的文件。 这些根文件按指定的顺序进行预处理。 在一个文件被加入列表前,它包含的所有三斜线引用都要被处理,还有它们包含的目标。 三斜线引用以它们在文件里出现的顺序,使用深度优先的方式解析。

/// <reference path="..." /> 指令是三斜线指令中最常见的一种,它用于声明文件间的依赖,参数是目标文件的相对路径,需要注意这儿的引用路径是相对于包含它的文件的,而不是根文件。

/// <reference types="..." /> 指令声明了对某个包的依赖。对这些包的名字的解析与在 import语句里对模块名的解析类似。 可以简单地把三斜线类型引用指令当做 import 声明的包。例如,把 /// <reference types="node" /> 引入到声明文件,表明这个文件使用了 @types/node/index.d.ts 里面声明的名字,并且这个包需要在编译阶段与声明文件一起被包含进来。对于那些在编译阶段生成的声明文件,编译器会自动地添加 /// <reference types="..." /> 但是当且仅当结果文件中使用了引用的包里的声明时才会在生成的声明文件里添加。

需要注意如果指定了我们在 tsc 指令中指定了 --noResolve 编译选项,三斜线引用会被忽略,它们不会增加新文件,也不会改变给定文件的顺序。当使用 --out 或 --outFile 选项时,它也可以做为调整输出内容顺序的一种方法。 文件在输出文件内容中的位置与经过预处理后的输入顺序一致。

配置文件

一、概述

如果一个目录下存在一个 tsconfig.json 文件,那么它意味着这个目录是 TypeScript 项目的根目录。 tsconfig.json文件中指定了用来编译这个项目的根文件和编译选项。 一个项目可以通过以下方式之一来编译:

  • 不带任何输入文件的情况下调用tsc,编译器会从当前目录开始去查找tsconfig.json文件,逐级向上搜索父目录。
  • 不带任何输入文件的情况下调用tsc,且使用命令行参数--project(或-p)指定一个包含tsconfig.json文件的目录。

当命令行上指定了输入文件时,tsconfig.json文件会被忽略。

二、配置属性

1、文件选项

(1)files: 包含相对或绝对文件路径的数组,指定编译器可以编译的文件列表。

(2)include: 文件glob匹配模式的数组,指定编译器可以编译的目录或文件列表。

(3)enclude: 文件glob匹配模式的数组,指定编译器可以编译的目录或文件列表,如果没有特殊指定,"exclude" 默认情况下会排除 node_modules,bower_components,jspm_packages和 "outDir" 目录。

"include" 和 "exclude" 属性指定一个文件 glob 匹配模式列表。 支持的 glob 通配符有:

  • '*' 匹配0或多个字符(不包括目录分隔符)
  • '?' 匹配一个任意字符(不包括目录分隔符)
  • '**/' 递归匹配任意子目录

如果一个 glob 模式里的某部分只包含 '' 或 '.',那么仅有支持的文件扩展名类型被包含在内(比如默认 .ts,.tsx,和 .d.ts, 如果 "allowJs" 设置为 true 还包含.js 和 .jsx)。

如果 "files" 和 "include" 都没有被指定,编译器默认包含当前目录和子目录下所有的 TypeScript 文件(.ts, .d.ts 和 .tsx),排除在 "exclude" 里指定的文件。如果 "allowJs" 属性被设为 true 那么 JS 文件(.js 和 .jsx)也被包含进来。 如果指定了 "files" 或 "include",编译器会将它们结合一并包含进来。 使用 "outDir" 指定的目录下的文件永远会被编译器排除,除非你明确地使用 "files" 将其包含进来(这时就算用exclude指定也没用)。

使用 "include" 引入的文件可以使用 "exclude" 属性过滤。 然而,通过 "files" 属性明确指定的文件却总是会被包含在内,不管 "exclude" 如何设置。 任何被 "files" 或 "include" 指定的文件所引用的文件也会被包含进来。 A.ts 引用了 B.ts,因此 B.ts 不能被排除,除非引用它的 A.ts 在 "exclude" 列表中。

需要注意编译器不会去引入那些可能做为输出的文件;比如,假设我们包含了 index.ts,那么 index.d.ts 和 index.js 会被排除在外。 通常来讲,不推荐只有扩展名的不同来区分同目录下的文件。

(4)extends: 指定继承的配置文件路径

extends的值是一个字符串,包含指向另一个要继承文件的路径。在原文件里的配置先被加载,然后被来至继承文件里的配置重写。 如果发现循环引用,则会报错。

(5)compileOnSave: 在最顶层设置compileOnSave标记,可以让IDE在保存文件的时候根据tsconfig.json重新生成文件(VSCode 不支持)。

2、编译选项

(1)incremental: 是否开启增量编译,如果开启编译时会生成增量编译文件,下次编译时会基于此文件进行增量编译。

(2)toBuildIntoFile: 包含绝对或相对文件路径的字符串,指明增量编译文件的存储位置。

(3)diagnostics: 是否打印诊断信息


(4)target: 编译生成语言的版本

(5)module: 编译生成语言的模块标准

(6)outFile: 将相互依赖的多个文件生成一个文件,可以用在AMD模块中


(7)lib: TypeScript需要声明的一些类库,即声明文件,es5 默认 "dom", "es5", "scripthost"


(8)allowJs: 允许编译 JS 文件(js、jsx)

(9)checkJs: 允许在 JS 文件中报错,可以提示相应的错误信息,通常与 allowJs 搭配使用

(10)outDir: 指定输出文件目录

(11)rootDir: 指定输入文件目录,默认就是当前目录(用于控制输出目录结构)


(12)declaration: 是否生成声明文件

(13)declarationDir: 声明文件的路径

(14)emitDeclarationOnly: 是否只生成声明文件

(15)sourceMap: 是否生成目标文件的 sourceMap

(16)inlineSourceMap: 是否生成目标文件的 inline sourceMap

(17)declarationMap: 是否生成声明文件的 sourceMap

(18)typeRoots: 数组,指定声明文件目录,默认包含 node_modules/@types

(19)types: 数组,声明文件包


(20)removeComments: 是否删除注释


(21)noEmit: 不输出文件

(22)noEmitOnError: 发生错误时不输出文件


(23)noEmitHelpers: TypeScript 中发生类的继承时编译生成的 JavaScript 代码中会生成 helper 函数,通过 "noEmitHelpers" 选项我们可以指定不生成 helper 函数,但同时也需要我们额外安装 ts-helpers,除此之外我们还可以通过开启 "importHelpers" 选项解决。

(24)importHelpers: 选项开启后编译生成的代码将通过 TypeScript 内置的 tslib 引入 helper 函数,但是需要注意文件必须是模块。


(25)downlevelInteration: 是否使用降级遍历器(es3/5)


(26)strict: 开启所有严格的类型检查,即开启该选项默认开启了下面所有选项

(27)alwaysStrict: 在代码中注入 "use strict"

(28)noImplictAny: 不允许隐式的 any 类型

(29)strictNullChecks: 不允许把 null、undefined 赋值给其他类型变量

(30)strictFunctiontypes: 不允许函数参数双向协变

(31)strictPropertyInitialization: 类的实例属性必须初始化

(32)strictBindCallApply: 严格的 bind/call/apply 检查

(33)noImplicitThis: 不允许 this 有隐式的 any 类型


以下配置与函数的执行有关,开启会对异常情况报错但不会阻碍编译的进行

(34)noUnusedLocals: 检查只声明未使用的局部变量

(35)noUnusedParameters: 检查未使用的函数参数

(36)noFallthroughCaseInSwitch: 防止 switch 语句贯穿

(37)noImplicitReturns: if/else 每个分支都要有返回值


(38)esModuleInterop: 允许 "export =" 导出,既可以通过 "import from" 导入,也可以通过 "import =" 的方式导入

(39)allowUmdGlobalAccess: 允许模块中访问 umd 全局变量

(40)moduleResolution: 模块解析策略,默认采用 node

TypeScript 中的解析策略分为 classic 和 node,classic 用于 AMD、System、ES2015 模块系统, 其路径解析策略如下图所示:

这一次,彻底掌握TypeScript(九)工程化实践

如果解析策略是 node 则路径解析策略有所不同:

这一次,彻底掌握TypeScript(九)工程化实践

(41)baseUrl: 解析非相对模块的基地址

(42)path: 路径映射,相对于 "baseUrl"

(43)rootDirs: 将多个目录放在一个虚拟目录下,用于运行时


(44)listEmitted: 打印输出的文件

(45)listFiles: 打印编译的文件(包括引用的声明文件)

参考资料

极客时间《TypeScript开发实战》专栏

《深入理解TypeScript》

CommonJs 和 ESModule 的 区别整理

TypeScript开发教程