概览和问题
在我们日常使用Typescript
的开发中, 从某个模块导入(import
)内容和导出(export
)内容是十分常见的, 但是Typescript 3.8
版本中专门引入了import type
和export type
, 不知道你是否使用过这两个特殊的方式去做过导入和导出, 又是否知道这两种方式的由来, 和它们与普通导入导出的区别呢?
这篇文章就import type
和export type
来做一些介绍, 首先, 抛出我们的问题:
- 只使用
import
和export
会有什么问题吗? - 为什么
Typescript
要引入import type
和export type
? import
和import type
有什么区别吗?
在此先做一个声明, 如果您还不曾使用过Typescript
, 或者还不曾完整的阅读过Typescript
的文档, 建议您先去阅读官方文档, 然后再继续来看这篇文章.
只使用import
会有什么问题吗?
首先我们要知道Typescript
做了哪些工作:
- 为你编写的
typescript
代码添加静态类型检查 - 将你编写的
ts+js
代码, 最终编译转化成javascript
代码. - 还能配合ide做更好的类型提示
但第二件事, 除了tsc
能做之外, 还存在别的编译工具可以做到, 比如我们最常用的Babel
.
首先说tsc
的编译, 在我们日常写代码的过程中, 通常会使用import
去导入一些类型或者值, 比如下面的写法:
// ./foo.ts
interface Options {
// ...
}
export function doThing(options: Options) {
// ...
}
// ./bar.ts
import { doThing, Options } from "./foo.js";
function doThingBetter(options: Options) {
// do something twice as good
doThing(options);
doThing(options);
}
上面的代码中, doThing
是作为一个值被导入, Options
作为一个类型被导入, 这样同时导入其实很方便, 因为我们不用担心我们导入的是什么, 只需要知道我要用它, import
就完事了, 哪怕我同时import
的是一个类型和一个实际值也没有关系.
但我们能够同时import
一个值和一个类型, 是因为一个叫**import elision
**(导入省略)的功能在起作用.
当Typescript
进行代码编译时, 发现Options
是作为一个类型被导入的, 就会自动在生成的JS
代码中删除掉它的导入, 所以最终生成的是类似于(类似, 用于解释说明, 但可能非实际输出代码)下面的JS
代码:
// ./foo.js
export function doThing(options: Options) {
// ...
}
// ./bar.js
import { doThing } from "./foo.js";
function doThingBetter(options) {
// do something twice as good
doThing(options);
doThing(options);
}
可见, 所有跟类型相关的代码, 都在最终编译生成的文件里被删除了, 所以我们直接通过import
和export
来导入/导出值和类型的写法是很方便的, 但是这么写, 也会存在一些问题.
问题1: 容易产生一些模棱两可的语句
利用3.8 release notes中例子来做说明, 在某些情况下, 可能会出现一些比较模棱两可的代码:
// ./some-module.ts
export interface MyThing {
name: string;
}
// ./src.ts
import { MyThing } from "./some-module.ts";
export { MyThing };
比如上面的例子, 如果把我们的分析仅仅限定在这个文件里, 仅凭文件里的这两行代码, 我们是难以分析出MyThing
到底是应该是一个值, 还是一个类型的.
如果MyThing
仅仅是作为一个类型而存在, 那么使用Babel
或者ts.transpileModule
API这样的工具最终编译出的javascript
代码是不能够正确的运行的, 编译生成的代码如下:
// ./some-module.js
----empty----
// ./src.js
Object.defineProperty(exports, "MyThing", {
enumerable: true,
get: function () {
return _test.MyThing;
}
});
var _test = require("./test1");
最终生成的src.js
文件中对MyThing
的导入和导出都会被保留, 而在some-module.js
文件中, MyThing
仅作为一个类型而存在, 会在编译过程中被删除, 所以最终some-module.js
文件是空的(可能会存在别的编译过程中的代码, 但是MyThing
的定义会被删除), 这样的代码会在运行时产生报错.
问题2 导入省略将删除引入副作用的代码
Typescript
的import elision
功能会删除掉仅作为类型使用的import
导入, 这在导入某些具有副作用的模块时, 会造成特别明显的影响, 让使用者不得不再写一个额外的语句去专门导入副作用:
// 因为import elision的功能, 这行代码在最终生成的js代码中会被删除
import { SomeTypeFoo, SomeOtherTypeBar } from "./module-with-side-effects";
// 这行代码要一直存在
import "./module-with-side-effects";
为什么要引入import type
基于上面的问题, Typescript 3.8
中引入了import type
, 希望能够用一种更加清晰易懂的方式来控制某一个导入是否要被删除掉.
import { MyThing } from "./some-module.ts";
export { MyThing };
上面的例子就是我们在问题1中介绍过的, 像Babel
这样的编译工具是不能够准确的识别MyThing
到底是一个值还是类型的, 因为Babel
在编译的过程中, 一次只会处理一个文件.
所以这种时候, 我们就需要一种方式, 来准确的告诉正在编译这个文件的编译工具, 现在使用import type
和export type
导入和导出的MyThing
就是一个类型, 你完全可以在编译的时候把这段代码省略(删除)掉.
import type { MyThing } from "./some-module.ts";
export type MyThing;
使用import type
和export type
导入和导出的类型只能在类型上下文中使用, 不能作为一个值来使用.
class
有一点需要注意的是, class
既可以代表一个类型, 也可以代表一个值, 它在runtime
是有实际的意义的, 所以用import type
引入一个class
的时候, 不能用到它值的含义, 不能extend
这个class
.
import type { Component } from "react";
interface ButtonProps {
// ...
}
class Button extends Component<ButtonProps> {
// error! 'Component' only refers to a type, but is being used as a value here.
}
不可以同时引入默认和命名绑定
使用import type
的时候, 可以引入一个默认导出的类型, 也可以引入命名绑定的形式导出的类型, 但是不可以同时使用两者.
import type Foo, { Bar, Baz } from "some-module";
// ~~~~~~~~~~~~~~~~~~~~~~
// error! A type-only import can specify a default import or named bindings, but not both.
import
和 import type
有什么区别吗
可以导入的内容相同, 但是使用的方式不同
import
和import type
都可以导入一个类型或一个值, 但是使用import type
导入的值, 只能在类型上下文中使用, 不能作为一个值来使用, 而import
导入的类型和值, 都可以按照其原本定义来使用.
// type.ts
export type UnionType = string | number;
export const unionValue: UnionType = '1';
// value.ts
import { UnionType, unionValue } from './type';
const value1: UnionType = 'value1'; // 作为类型使用
const value2: typeof unionValue = 'value2'; // 获取类型
const value3 = unionValue; // 作为值使用
// value1.ts
import type { UnionType, unionValue } from './type';
const value1: UnionType = 'value1'; // 作为类型使用
const value2: typeof unionValue = 'value2'; // 获取类型
// Error: "unionValue" 是使用 "import type" 导入的,因此不能用作值
const value3 = unionValue; // 作为值使用
可以看到上面的代码, 分别使用import
和import type
引入了一个类型UnionType
和一个值uninoValue
, 在value1.ts
文件中, 使用import type
导入的值unionValue
, 直接作为一个值使用时, 是会报错的.
import type
导入的内容只能用在跟类型相关的地方.
跟import type
有关的tsconfig.json
项
importsNotUsedAsValues
跟import type
一起, 在3.8
版本引入的还有一个编译项importsNotUsedAsValues
来控制怎么处理在runtime
阶段不会用到的import
(注意这里是控制import
, 所有使用import type
的都会在编译阶段被删除), 这个编译项有三个值:
-
remove
:默认行为, 会删除那些仅作为类型出现的导入.remove
: The default behavior of droppingimport
statements which only reference types.上面的是官方文档的原文, 不确定是我理解的有问题还是我翻译的有问题, 在我实际测试中,
which only reference types
好像不成立, 如果是一个实际的value
被引入但是没有使用也一样会被删除. -
preserve
: 保留所有值或者类型没有被使用的导入. 这种方式会保留所有的导入和副作用. -
error
: 这种方式将保留所有的导入(跟preserve一样), 但如果导入的一个值只作为类型被使用, 则会报错.
也可以直接使用import type
去导入一些不会被编译到javascript
中的类型.
下面是我针对这个配置项做的测试, 首先是ts的代码:
// ./test.ts
export const exportValue = 100;
export type ExportType = '1' | '2';
// ./importType.ts
// 使用import 去导入一个类型, 但该文件中没有使用该类型
import { ExportType } from './test';
export const importType = 'importType';
// ./importValue.ts
// 使用import导入一个值, 但是该文件中没有使用该值
import { exportValue } from './test';
export const importValue = 'importValue';
// ./useImportType.ts
// 使用import type导入一个类型, 但是该文件中没有使用该类型
import type { ExportType } from './test';
export const useImportType = 'useImportType';
// ./valueUseAsType.ts
// 使用import导入一个值, 但是只作为类型使用
import { exportValue } from './test';
const valueUseAsType: typeof exportValue = 100;
// ./importTypeUseAsType.ts
// 使用import type导入一个值, 但是只作为类型使用
import type { exportValue } from './test';
const valueUseAsType: typeof exportValue = 100;
下面使用tsc编译的代码, module
项的值是ESNext
, 看下importsNotUsedAsValues
设置不同的值之后, tsc
编译后的js代码:
// importsNotUsedAsValues设置为remove
// ./test.js
export var exportValue = 100;
// ./importType.js
// 使用import 去导入一个类型, 但该文件中没有使用该类型
// 生成的代码中, import语句被删除
export var importType = 'importType';
// ./importValue.js
// 使用import导入一个值, 但是该文件中没有使用该值
// 生成的代码中, import语句被删除
export var importValue = 'importValue';
// ./useImportType.js
// 使用import type导入一个类型, 但是该文件中没有使用该类型
// 生成的代码中, import语句被删除
export var useImportType = 'useImportType';
// ./valueUseAsType.js
// 使用import导入一个值, 但是只作为类型使用
// 生成的代码中, import语句被删除
var valueUseAsType = 100;
export {};
// ./importTypeUseAsType.js
// 使用import type导入一个值, 但是只作为类型使用
// 生成的代码中, import语句被删除
var valueUseAsType = 100;
export {};
上面代码可以看到, 当我们把importsNotUsedAsValues
设置为remove
时, importValue.js
最后输出的内容里, 没有使用的exportValue
也被删除了.
然后是设置为preserve
, 即使导入的内容没有使用, 但是依然会保留导入的语句, 这样如果导入的文件中存在副作用, 就会被引入:
// importsNotUsedAsValues设置为preserve
// ./test.js
export var exportValue = 100;
// ./importType.js
// 使用import 去导入一个类型, 但该文件中没有使用该类型
// 生成的代码中, import语句被保留
import './test';
export var importType = 'importType';
// ./importValue.js
// 使用import导入一个值, 但是该文件中没有使用该值
// 生成的代码中, import语句被保留
import './test';
export var importValue = 'importValue';
// ./useImportType.js
// 使用import type导入一个类型, 但是该文件中没有使用该类型
// 生成的代码中, import语句被删除
export var useImportType = 'useImportType';
// ./valueUseAsType.js
// 使用import导入一个值, 但是只作为类型使用
import './test';
var valueUseAsType = 100;
// ./importTypeUseAsType.js
// 使用import type导入一个值, 但是只作为类型使用
// 生成的代码中, import语句被删除
var valueUseAsType = 100;
export {};
最后是设置为error
, 它的表现跟preserve
类似, 但是遇到下面情况就会报错:
// 上面的代码在编译的时候, valueUseAsType.ts文件会报错
// error TS1371: This import is never used as a value and must use 'import type' because 'importsNotUsedAsValues' is set to 'error'.
1 // import { exportValue } from './test';
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Found 1 error.
isolatedModules
我们可以使用Typescript
将我们的typescript
代码转化为javascript
代码, 但同时我们也可以使用像Babel
这样的编译器去把我们的typescript
代码转化为javascript
代码. 但是像Babel
的编译工具, 一次只能处理单个文件, 这就意味着它不能够依赖完整的类型系统进行代码的转换.
只能处理单文件的限制, 可能会导致某些Typescript
功能(比如const enum
和namespace
)出现运行时问题. Typescript
中存在isolatedModules
编译选项, 打开了isoloatedModules
开关, 如果你写的代码, 不能够被像Babel
这样的单文件处理工具正确处理, 就会发出警告.
isoloatedModules
不会对你的代码做出任何改变, 也不会影响Typescript
的类型检查和编译.
换句话说, 打开了isoloatedModules
后, 每一个ts
文件都必须能够只依赖自身完成编译, isoloatedModules
会阻止我们写入一些模棱两可的模糊的import
.
babel不支持的typescript语法
因为babel
与tsc
存在编译过程的差异, 所以存在一些typescript
语法是babel
不支持的:
-
const enum不支持.
const enum
是在编译过程中把enum
的引用替换成具体的值, 需要解析类型信息, 而babel
并不会解析, 所以不支持. -
namespace部分支持.
不支持
namespace
的跨文件合并, 不支持导出非const
的值。这也是因为babel
不会解析类型信息且是单文件编译.以上两点是因为编译方式不同导致的不支持.
-
export = / import = 这种 ts 特有语法不支持.
-
如果开启了
jsx
编译,那么<string> aa
这种类型断言不支持, 通过aa as string
来替代.
具体示例
示例1 - Babel不能正确处理的代码
假设我们有如下的代码, type.ts
文件中只定义了一些类型, re-export.ts
文件中直接re-export
了这些类型.
// src/types.ts
export type Playlist = {
id: string;
name: string;
trackIds: string[];
};
export type Track = {
id: string;
name: string;
artist: string;
duration: number;
};
// src/re-export.ts
export { Playlist, Track } from "./types";
因为Babel
依赖的单文件的处理方式, 所以Babel
会做如下处理:
- 因为
type.ts
文件中, 只包含类型, 不包含会在Javascript
层面产生任何实际意义的代码, 所以type.ts
中的所有内容都会被删除. Babel
并不会对re-export.ts
文件做转译, 因为对于Babel
来说, 只依赖re-export.ts
文件, 并不能准确的判断出Playlist
和Track
只是作为类型存在, 所以这两个re-export
的操作会在编译后的代码中被保留, 所以也就导致了, 编译后的代码, 在运行时是会出错的, 因为编译后的type.js
文件是空的, 不存在可导出的PlayList
和Track
等.
编译后的代码如下:
// dist/types.js
--EMPTY, 没有具有实际意义的代码--
Object.defineProperty(exports, "__esModule", {
value: true
});
// dist/re-export.js
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
Object.defineProperty(exports, "Playlist", {
enumerable: true,
get: function () {
return _type.Playlist;
}
});
Object.defineProperty(exports, "Track", {
enumerable: true,
get: function () {
return _type.Track;
}
});
var _type = require("./type");
但如果打开了isolatedModules
的开关, 那么在我们的编辑器中, 在re-export.ts
文件使用export
重新导出类型的地方就会有如下的报错:
提供 "--isolatedModules" 标志时,需要使用 "export type" 才能重新导出类型
这样就可以避免产生模棱两可的, Babel
编译之后会产生报错的代码.
示例2 - 显式的import type
和显式的export type
下面让我们来看一个使用import
导入, 然后使用export type
导出的例子.
// src/types.ts
export type Playlist = {
id: string;
name: string;
trackIds: string[];
};
export type Track = {
id: string;
name: string;
artist: string;
duration: number;
};
// src/value.ts
export type PlayType = '100' | '200';
export const playValue: PlayType = '100';
// src/re-export.ts
import type { Playlist, Track } from './type';
import type { PlayType } from './value';
import { playValue } from './value';
export type { Playlist, Track, PlayType };
export { playValue };
Babel
转义后(可能非实际生成代码, 只是用于解释说明)的输出为:
// dist/types.js
--empty, 没有具有实际意义的代码遗留--
// dist/value.js
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.playValue = void 0;
const playValue = '100';
exports.playValue = playValue;
// dist/re-export.js
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
Object.defineProperty(exports, "playValue", {
enumerable: true,
get: function () {
return _value.playValue;
}
});
var _value = require("./value");
可以看出, type.ts
内的所有类型都被删除, 而re-export.ts
中的所有跟类型先关的内容也都被删除, 只保留了作为实际值使用的playValue
, 这是因为所有的import type/export type
会被自动删除掉.