import 和 import type的区别

18,032 阅读13分钟

概览和问题

在我们日常使用Typescript的开发中, 从某个模块导入(import)内容和导出(export)内容是十分常见的, 但是Typescript 3.8版本中专门引入了import typeexport type, 不知道你是否使用过这两个特殊的方式去做过导入和导出, 又是否知道这两种方式的由来, 和它们与普通导入导出的区别呢?

这篇文章就import typeexport type来做一些介绍, 首先, 抛出我们的问题:

  1. 只使用importexport会有什么问题吗?
  2. 为什么Typescript要引入import typeexport type?
  3. importimport type有什么区别吗?

在此先做一个声明, 如果您还不曾使用过Typescript, 或者还不曾完整的阅读过Typescript的文档, 建议您先去阅读官方文档, 然后再继续来看这篇文章.

只使用import会有什么问题吗?

首先我们要知道Typescript做了哪些工作:

  1. 为你编写的typescript代码添加静态类型检查
  2. 将你编写的ts+js代码, 最终编译转化成javascript代码.
  3. 还能配合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);
}

可见, 所有跟类型相关的代码, 都在最终编译生成的文件里被删除了, 所以我们直接通过importexport来导入/导出值和类型的写法是很方便的, 但是这么写, 也会存在一些问题.

问题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.transpileModuleAPI这样的工具最终编译出的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 导入省略将删除引入副作用的代码

Typescriptimport 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 typeexport type导入和导出的MyThing就是一个类型, 你完全可以在编译的时候把这段代码省略(删除)掉.

import type { MyThing } from "./some-module.ts";

export type MyThing;

使用import typeexport 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.

importimport type 有什么区别吗

可以导入的内容相同, 但是使用的方式不同

importimport 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; // 作为值使用

可以看到上面的代码, 分别使用importimport 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 dropping import 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 enumnamespace)出现运行时问题. Typescript中存在isolatedModules编译选项, 打开了isoloatedModules开关, 如果你写的代码, 不能够被像Babel这样的单文件处理工具正确处理, 就会发出警告.

isoloatedModules不会对你的代码做出任何改变, 也不会影响Typescript的类型检查和编译.

换句话说, 打开了isoloatedModules后, 每一个ts文件都必须能够只依赖自身完成编译, isoloatedModules会阻止我们写入一些模棱两可的模糊的import.

babel不支持的typescript语法

因为babeltsc存在编译过程的差异, 所以存在一些typescript语法是babel不支持的:

  1. const enum不支持.

    const enum是在编译过程中把enum的引用替换成具体的值, 需要解析类型信息, 而babel并不会解析, 所以不支持.

  2. namespace部分支持.

    不支持namespace的跨文件合并, 不支持导出非const的值。这也是因为babel不会解析类型信息且是单文件编译.

    以上两点是因为编译方式不同导致的不支持.

  3. export = / import = 这种 ts 特有语法不支持.

  4. 如果开启了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会做如下处理:

  1. 因为type.ts文件中, 只包含类型, 不包含会在Javascript层面产生任何实际意义的代码, 所以type.ts中的所有内容都会被删除.
  2. Babel并不会对re-export.ts文件做转译, 因为对于Babel来说, 只依赖re-export.ts文件, 并不能准确的判断出PlaylistTrack只是作为类型存在, 所以这两个re-export的操作会在编译后的代码中被保留, 所以也就导致了, 编译后的代码, 在运行时是会出错的, 因为编译后的type.js文件是空的, 不存在可导出的PlayListTrack等.

编译后的代码如下:

// 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会被自动删除掉.

参考

  1. typescript 3.8 release notes
  2. 官网文档
  3. type only imports
  4. 为什么说用 babel 编译 typescript 是更好的选择