跳到主要内容
预计阅读 36 分钟

当项目膨胀到失控——模块化拆分与类型声明的工程实践

从一次线上事故说起

某支付平台在一次常规迭代后上线,结果调用方报告接口返回格式异常。排查发现,后端团队修改了一个公共工具函数的参数类型,但前端引用的类型定义没有同步更新——因为类型信息散落在各个文件的全局作用域中,没有人能确定哪些模块依赖了这个函数。更糟糕的是,打包产物中混入了大量仅在编译期使用的类型导入,导致 bundle 体积增大了 15%。

这个事故暴露了三个典型问题:代码没有模块化隔离、类型导入和值导入混为一体、第三方库缺少类型定义时的应急机制缺失。这并非个例——在缺乏模块化规范的 TypeScript 项目中,随着代码量增长,全局命名冲突、打包产物冗余和第三方库类型缺失是最常见的三类工程问题。

本章围绕这三个问题展开,从 ES Module 的规范用法讲到声明文件体系,最终建立一套适用于大型工程的类型基础设施。读完本章后,你将能够回答以下问题:什么时候应该用 import type 而不是 import?拿到一个没有类型的第三方库该怎么处理?命名空间和模块到底有什么区别,为什么官方建议不再使用命名空间?


问题一:全局作用域污染如何根治?

文件什么时候算”模块”

TypeScript 判断一个文件是”模块”还是”脚本”的规则非常简单:文件中是否存在顶层的 importexport 语句。如果存在,文件内的所有声明都被限制在模块作用域内;如果不存在,所有声明都暴露到全局。

// payment-utils.ts —— 没有 import/export,所有声明进入全局
const TAX_RATE = 0.13;
function computeTax(amount: number): number {
  return amount * TAX_RATE;
}

上面这段代码中,TAX_RATEcomputeTax 对项目中所有文件都可见。如果另一个文件也声明了同名变量,TypeScript 会报重复定义错误。

解决方案是让每个文件成为模块。即使一个文件不需要对外暴露任何东西,也可以加一行空导出:

export {};
const TAX_RATE = 0.13;
// 现在这个常量只在本文件内可见

导出的几种写法

TypeScript 完全兼容 ES Module 的导出语法,在实际工程中最常见的有三种模式。

就地导出——声明的同时导出,适合公共工具模块:

export const INTEREST_RATE = 0.035;

export function calculateCompound(principal: number, years: number): number {
  return principal * Math.pow(1 + INTEREST_RATE, years);
}

export interface LoanRecord {
  borrower: string;
  amount: number;
  term: number;
}

集中导出——先声明后统一导出,适合内部实现较多、只暴露部分接口的模块:

const INTEREST_RATE = 0.035;

function calculateCompound(principal: number, years: number): number {
  return principal * Math.pow(1 + INTEREST_RATE, years);
}

function internalValidate(amount: number): boolean {
  return amount > 0 && Number.isFinite(amount);
}

export { INTEREST_RATE, calculateCompound };
// internalValidate 不导出,外部无法访问

默认导出——每个模块只能有一个,通常用于导出模块的”主角”:

export default class TransactionProcessor {
  process(amount: number): { netAmount: number; fee: number } {
    const fee = amount * 0.002;
    return { netAmount: amount - fee, fee };
  }
}

导出时还可以用 as 重命名,在做模块重构时非常有用:

export { calculateCompound as computeInterest, LoanRecord as BorrowingInfo };

导入的完整形态

// 按名称导入
import { INTEREST_RATE, calculateCompound } from './finance';

// 导入时重命名,解决命名冲突
import { calculateCompound as calcInterest } from './finance';

// 导入默认导出
import TransactionProcessor from './processor';

// 整体导入为命名空间对象
import * as FinanceKit from './finance';
FinanceKit.calculateCompound(10000, 5);

注意在 TypeScript 中导入路径不需要写 .ts 后缀:

import { OrderDetail } from './models/order'; // 不写 .ts

在实际工程中,一个常见的争论是”应该用默认导出还是命名导出”。两种方式在语法上没有优劣之分,但命名导出有一个明显的工程优势:IDE 的自动导入功能对命名导出的支持更好,因为导入方必须使用与导出方一致的名称,不容易出现一个模块被不同文件以不同名字导入的混乱局面。默认导出在单一职责的模块中(例如一个文件只导出一个 React 组件或一个类)依然很常见,但如果一个模块对外暴露多个符号,命名导出几乎总是更好的选择。

另一个值得注意的细节是重新导出(re-export)。当你需要把多个子模块的导出汇聚到一个入口文件时,可以使用 export ... from 语法:

// index.ts —— 模块入口,汇聚所有子模块的导出
export { calculateCompound, INTEREST_RATE } from './finance';
export { TransactionProcessor } from './processor';
export type { LoanRecord } from './finance';

这种模式在库开发中非常常见。消费方只需要 import { ... } from 'your-lib',而不必关心库的内部文件结构。


问题二:编译产物里为什么混入了无用代码?

值导入与类型导入的边界

在 TypeScript 中,import 可以同时导入值(变量、函数、类的实例化能力)和类型(接口、类型别名、类的类型签名)。但编译为 JavaScript 后,类型信息应当被完全擦除。如果打包工具无法判断一个导入是纯类型还是运行时依赖,就可能保留多余的 import 语句,影响 tree-shaking 效果。

import type:明确标记编译期依赖

TypeScript 3.8 引入了 import type 语法,显式告诉编译器和打包工具:这个导入只在类型层面使用,编译后请完全移除。TypeScript 4.5 进一步引入了内联 type 标记,允许在同一条 import 语句中混合导入值和类型:

// 整条语句都是类型导入(TS 3.8+)
import type { OrderDetail, PaymentMethod } from './contracts';

// 混合导入:runPayment 是运行时需要的,OrderDetail 只是类型(TS 4.5+)
import { runPayment, type OrderDetail, type PaymentMethod } from './payment';

type 标记的导入不能在运行时位置使用:

import type { OrderDetail } from './contracts';

// 编译错误:OrderDetail 是类型,不能当构造函数
const order = new OrderDetail();

// 正确用法:仅出现在类型注解位置
const order: OrderDetail = { itemId: 'SKU-001', quantity: 3, unitPrice: 29.9 };

export type:从源头控制暴露方式

导出端同样有对应的类型专用语法:

export type { OrderDetail, PaymentMethod };

// 或者在混合导出中逐个标记
export { runPayment, type OrderDetail, type PaymentMethod };

一个实用场景是把类仅作为类型导出。这样导入方可以用它做类型注解,但不能实例化:

class Coordinate {
  constructor(public lat: number, public lng: number) {}
}

export type { Coordinate }; // 只导出类型签名
import type { Coordinate } from './geo';

const point: Coordinate = { lat: 31.23, lng: 121.47 }; // 合法
const point2 = new Coordinate(31.23, 121.47);           // 编译错误

为什么要严格区分?

  1. 打包优化:打包工具可以安全剥离纯类型导入,减小产物体积
  2. 循环依赖缓解:类型导入不产生运行时模块依赖,能打破循环引用链
  3. 意图表达:代码审查时一眼就能区分编译期依赖和运行时依赖
  4. 工具链要求:在 isolatedModules 模式下(Vite、esbuild 等单文件编译场景),纯类型必须使用 import type

问题三:第三方库没有类型怎么办?

声明文件是什么

声明文件(.d.ts)是一种只包含类型信息、不包含任何可执行代码的特殊文件。它的作用是为已有的 JavaScript 代码提供类型描述,让 TypeScript 编译器能够进行类型检查和智能提示。

declare 关键字:描述外部存在

declare 告诉编译器:“这个东西的实现在别处,我只负责描述它的类型轮廓。”

描述全局变量

// 页面上通过 <script> 标签引入了一个监控 SDK
declare var tracker: {
  send(eventName: string, payload: Record<string, unknown>): void;
  setUser(uid: string): void;
};

描述全局函数(支持重载):

declare function formatCurrency(amount: number): string;
declare function formatCurrency(amount: number, currency: string): string;

描述全局类

declare class EventBridge {
  on(event: string, handler: (...args: unknown[]) => void): void;
  emit(event: string, ...args: unknown[]): void;
  off(event: string, handler: (...args: unknown[]) => void): void;
}

描述命名空间——用于刻画全局库的层级结构:

declare namespace Metrics {
  function record(name: string, value: number): void;
  const version: string;
  namespace internal {
    function flush(): Promise<void>;
  }
}

declare module:给第三方模块补类型

当一个 npm 包既不自带类型也没有社区 @types 包时,可以用 declare module 快速补全:

最简声明——消除编译错误,但不提供任何类型检查:

declare module 'legacy-csv-parser';

详细声明——提供完整的类型约束:

declare module 'legacy-csv-parser' {
  interface ParseOptions {
    delimiter: string;
    hasHeader: boolean;
    encoding?: BufferEncoding;
  }

  function parse(input: string, options?: ParseOptions): string[][];
  function stringify(data: string[][]): string;

  export { parse, stringify, ParseOptions };
}

扩展已有模块——给第三方库的类型定义追加字段:

// 给 express 的 Request 对象挂载业务字段
declare module 'express' {
  interface Request {
    tenantId?: string;
    traceId?: string;
  }
}

通配符匹配——为非代码资源声明模块类型:

declare module '*.graphql' {
  import { DocumentNode } from 'graphql';
  const content: DocumentNode;
  export default content;
}

declare module '*.module.less' {
  const classMap: Record<string, string>;
  export default classMap;
}

declare global:在模块文件中扩展全局

如果当前文件本身是一个模块(有 importexport),要扩展全局类型就需要用 declare global

export {};

declare global {
  interface Window {
    featureFlags: {
      enableNewCheckout: boolean;
      enableDarkMode: boolean;
    };
  }
}

这里有一个容易踩的坑:declare global 只能出现在模块文件中(即文件中至少有一个 importexport 语句)。如果文件本身就是脚本(没有任何导入导出),那么它的声明本来就是全局的,直接写 interface Window { ... } 就行了。但如果你在一个带有 import 的文件中直接写 interface Window,这个声明只在模块作用域内生效,不会扩展到全局。这是初学者最常犯的错误之一。

声明合并的实际场景

同名的 declare 声明会自动合并。这个特性在描述那些”既是函数又有静态属性”的全局对象时非常有用。例如,很多老式 JavaScript 库的主入口既可以作为函数调用,又可以通过属性访问子功能:

declare function analytics(event: string, data?: Record<string, unknown>): void;
declare namespace analytics {
  function setUserId(uid: string): void;
  function flush(): Promise<void>;
  const sdkVersion: string;
}

// 两种用法都合法
analytics('page_view', { url: '/home' });
analytics.setUserId('u_12345');

这种合并机制是 TypeScript 声明文件能够精确描述各种 JavaScript 库 API 形态的关键能力。


被淘汰但必须认识的:命名空间

基本结构

命名空间是 TypeScript 早期(ES Module 标准尚未落地时)的代码组织方案,通过 namespace 关键字创建封闭作用域:

namespace CurrencyConverter {
  const rates: Record<string, number> = { USD: 1, CNY: 7.24, EUR: 0.92 };

  export function convert(amount: number, from: string, to: string): number {
    return (amount / rates[from]) * rates[to];
  }

  export function supportedCurrencies(): string[] {
    return Object.keys(rates);
  }
}

CurrencyConverter.convert(100, 'USD', 'CNY'); // 724

编译后命名空间变成 IIFE(立即执行函数),导出成员挂载到一个对象上:

var CurrencyConverter;
(function (CurrencyConverter) {
  var rates = { USD: 1, CNY: 7.24, EUR: 0.92 };
  function convert(amount, from, to) {
    return (amount / rates[from]) * rates[to];
  }
  CurrencyConverter.convert = convert;
  // ...
})(CurrencyConverter || (CurrencyConverter = {}));

嵌套与合并

命名空间支持嵌套,同名命名空间会自动合并:

namespace Platform {
  export namespace Auth {
    export function verifyToken(token: string): boolean {
      return token.length > 0;
    }
  }
}

namespace Platform {
  export namespace Billing {
    export function charge(accountId: string, cents: number): void {
      console.log(`Charging ${cents} cents to ${accountId}`);
    }
  }
}

// 合并后 Platform 同时包含 Auth 和 Billing
Platform.Auth.verifyToken('abc');
Platform.Billing.charge('acct_01', 9900);

命名空间还能与函数或类合并,为它们添加静态属性:

function sendAlert(msg: string) {
  return `[${sendAlert.level}] ${msg}`;
}

namespace sendAlert {
  export let level = 'WARNING';
}

sendAlert('disk full'); // '[WARNING] disk full'

为什么现代项目不用命名空间

维度命名空间ES Module
标准化TypeScript 私有语法ECMAScript 标准
隔离粒度手动划分以文件为天然边界
按需加载不支持天然支持
Tree-shaking不支持支持
工具链兼容所有现代工具原生支持

官方建议:新项目始终使用 ES Module。命名空间仅在编写 .d.ts 声明文件时偶尔需要。


DefinitelyTyped 与 @types 生态

三层类型来源

一个 npm 包的类型信息可能来自三个层级:

  1. 包自带——查看 package.json 中的 typestypings 字段
  2. 社区 @types 包——DefinitelyTyped 仓库维护,发布到 @types/ 命名空间下
  3. 项目内手写——以上两者都没有时的兜底方案
# 安装社区类型包
npm install --save-dev @types/express @types/lodash @types/node

安装后类型文件存放在 node_modules/@types/ 下,TypeScript 编译器会自动扫描这个目录。

可以通过 tsconfig.jsontypeRoots 配置自定义扫描路径:

{
  "compilerOptions": {
    "typeRoots": ["./custom-types", "./node_modules/@types"]
  }
}

判断一个库是否需要单独安装 @types 包,有一个简单的经验法则:先在项目中直接 import 这个库,如果 TypeScript 没有报错,说明库本身自带类型定义,不需要额外安装。如果报了”找不到模块声明”的错误,就去 npm 上搜 @types/库名。如果社区包也没有,就需要自己在项目中创建声明文件了。

另一个值得注意的问题是 @types 包的版本管理。@types 包的版本号通常与对应库的主版本号保持一致。例如 express@4.x 对应 @types/express@4.x。如果版本不匹配,可能会出现类型定义与实际 API 不一致的问题。在团队协作中,建议在 package.json 中锁定 @types 包的版本范围,避免不同成员安装到不同版本的类型定义。


工程化实践:项目内的类型基础设施

推荐的目录结构

payment-service/
  src/
    index.ts
    routes/
    services/
  typings/
    global.d.ts         -- 全局类型(无需 import 即可使用)
    environment.d.ts    -- 环境变量类型
    assets.d.ts         -- 非代码资源的模块声明
  tsconfig.json

全局类型global.d.ts):

type EntityId = string | number;

interface ServiceResponse<T> {
  code: number;
  msg: string;
  payload: T;
}

环境变量类型environment.d.ts):

declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'production' | 'staging';
    DATABASE_DSN: string;
    REDIS_URL: string;
    JWT_SECRET: string;
  }
}

资源模块声明assets.d.ts):

declare module '*.svg' {
  const url: string;
  export default url;
}

declare module '*.yaml' {
  const data: Record<string, unknown>;
  export default data;
}

自动生成声明文件

开发 TypeScript 库时,可以让编译器自动输出 .d.ts

{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist/typings",
    "declarationMap": true,
    "emitDeclarationOnly": false,
    "outDir": "./dist"
  }
}
  • declaration:开启声明文件输出
  • declarationDir:声明文件的输出位置
  • declarationMap:生成声明文件的 source map,让 IDE 可以从 .d.ts 跳转到 .ts 源码

三斜线指令

三斜线指令是出现在文件最顶部的特殊注释,用于声明文件之间的引用关系:

/// <reference path="./common.d.ts" />
/// <reference types="node" />
/// <reference lib="es2020.promise" />
  • path:引用另一个本地声明文件
  • types:引用 @types
  • lib:引用内置的类型库

现代项目中三斜线指令的大部分用途已被 tsconfig.jsontypeslib 配置取代。但在 .d.ts 文件中声明对 @types 包的依赖时,/// <reference types="..." /> 仍然是推荐做法。例如 Vite 项目中常见的 env.d.ts

/// <reference types="vite/client" />

这行指令让 TypeScript 加载 vite/client 的类型定义,从而获得 import.meta.env 等 Vite 特有 API 的类型支持。类似的用法还出现在 @types/node@types/jest 等场景中。


模块解析:TypeScript 如何找到文件

相对路径 vs 裸标识符

// 相对路径:以 ./ 或 ../ 开头
import { OrderService } from './services/order';

// 裸标识符:不含路径信息
import express from 'express';

解析顺序

对于 import { OrderService } from './services/order',TypeScript 依次查找:

  1. ./services/order.ts
  2. ./services/order.tsx
  3. ./services/order.d.ts
  4. ./services/order/package.json 中的 types 字段
  5. ./services/order/index.ts
  6. ./services/order/index.tsx
  7. ./services/order/index.d.ts

对于裸标识符,TypeScript 沿目录树逐级搜索 node_modules

路径别名

通过 tsconfig.json 配置路径映射,告别深层嵌套的相对路径:

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@services/*": ["services/*"],
      "@models/*": ["models/*"],
      "@config": ["config/index"]
    }
  }
}
import { OrderService } from '@services/order';
import { loadSettings } from '@config';

注意:paths 只解决编译期路径解析,运行时还需要在打包工具(Vite 的 resolve.alias、webpack 的 resolve.alias)中做对应配置。这是一个非常容易忽略的细节——TypeScript 编译不报错不代表运行时不会报错。曾经有团队在 tsconfig.json 中配好了 paths,开发环境一切正常(因为 Vite 默认会读取 tsconfig 中的路径映射),但在单元测试中(使用 Jest)就报找不到模块,因为 Jest 需要在自己的配置中通过 moduleNameMapper 单独映射一遍。

解析问题排查

遇到”找不到模块”时,可以用 --traceResolution 标志查看编译器的完整查找过程:

tsc --traceResolution 2>&1 | head -50

本章回顾

本章从一个生产事故出发,系统解决了三个问题:

  • 全局污染 —— 用 ES Module 的 import/export 建立文件级作用域隔离
  • 产物膨胀 —— 用 import type/export type 严格分离编译期依赖和运行时依赖
  • 第三方库缺类型 —— 用 .d.ts 声明文件、declare 关键字和 @types 生态补全类型信息

同时认识了命名空间这一历史遗留机制,理解了它在声明文件中的残余价值。在大型项目中,清晰的模块边界和完善的类型声明体系是代码质量的基石。

最后给一个实用建议:在项目初始化阶段就建好 typings/ 目录,创建 global.d.tsenvironment.d.tsassets.d.ts 三个文件。前期投入不到十分钟,却能在项目整个生命周期中避免大量”类型找不到”的低级问题。类型基础设施和单元测试一样,越早建立,收益越大。

购买课程解锁全部内容

告别类型错误:12 章掌握 TypeScript 工程实战

¥29.90