这几天有几个朋友不约而同地来问我同一个问题:TypeScript 写了几千行之后,总觉得代码像一锅粥,明明分了文件,改起来还是提心吊胆。我一看他们的代码,问题基本都出在同一个地方——对模块化的理解还停留在"把一个文件拆成几个文件"的层面。
TS 模块化这个东西,表面上看就是 export 和 import 两个关键字的事,但真把它用明白,牵扯到模块系统选型、类型文件的组织方式、运行时和编译时的边界、编译产物对实际项目的影响,甚至还有路径别名、循环依赖这些工程细节。网上讲 TS 模块化的文章不少,但大多要么只讲语法,要么只讲配置,很少有人把"为什么要这样设计""实际项目里到底怎么组织"讲透。
这篇我就把自己在多个中大型项目里摸爬滚打出来的经验梳理一遍,从模块系统的底层逻辑讲到工程落地,最后再把我踩过的坑和正在用的推荐实践一并交代清楚。内容不薄,但每一节都能直接落地。
1. 模块化的本质:它到底解决了什么问题
先说一个我反复跟人强调的观点:模块化不是"把代码拆开",模块化的本质是作用域隔离和依赖关系显式化。搞不清楚这两点,拆出来的文件再多也是一堆散沙。
在 JavaScript 的世界里,早期的 script 标签方式有个著名的问题——全局作用域污染。A 文件定义了一个 let utils,B 文件也定义了一个 let utils,后加载的覆盖先加载的,排查起来极其痛苦。后来的 CommonJS 和 AMD 解决了一部分问题,但 Node 环境的 require 是运行时同步加载,浏览器环境用起来又别扭。
TypeScript 做的第一件正确的事,就是在语言层面把模块系统固化了。在 TS 里,一个文件只要包含 export 或 import 语句,它就被视为一个模块,这个文件里声明的所有顶层变量、函数、类、类型,默认都是模块私有的,外部拿到的是一个受控的导出接口。这就是作用域隔离——每个模块是封闭的沙箱。
typescript复制// math.ts
const internalHelper = '这个变量外面看不到';
export function add(a: number, b: number): number {
return a + b;
}
typescript复制// app.ts
import { add } from './math';
console.log(add(1, 2));
// console.log(internalHelper); // 报错:找不到名字
依赖关系显式化是什么意思?就是每个文件用了什么、被谁用,都是明明白白写在代码里的。模块 A 改了导出签名,所有 import 过它的地方立刻在编译时报错,而不是等运行到某一处才炸。这种"契约"机制,才是模块化真正让项目变得可维护的核心原因。
从编译产物来看,TS 源码里的模块化最终会被编译成对应模块系统(CommonJS、ESM、UMD 等)的代码,具体走哪条路取决于 tsconfig.json 里的 module 选项。这里有个很多人忽略的细节:module 只是"编译产物用什么模块格式",而模块解析策略(moduleResolution)是另一回事,后面我会专门讲这两个配置如何正确搭配。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 编译配置层的模块化选型:module 和 moduleResolution 到底该怎么设
很多初学者拿到 tsconfig.json,看到 module 就填 esnext,看到 moduleResolution 就填 bundler,完全不知道为什么。等到项目跑不起来或者类型报错,才开始瞎试。这里我把逻辑理清。
module 决定的是 TypeScript 编译器生成的 JavaScript 代码里用什么模块语法来 import/require。常见取值有 commonjs、esnext、es2015、amd、umd 等。选它的依据只有一个:你的代码最终跑到什么环境,这个环境对模块语法和加载器的支持情况如何。
- Node.js 的 CommonJS 项目(大多数后端服务),用
module: "commonjs"。产物代码里import { add } from './math'会变成require("./math")。 - 纯浏览器项目且使用原生 ES Module 加载(
<script type="module">),用module: "esnext"或es2015,产物保留import语法。 - 用打包器(Vite、Webpack、Rollup、esbuild)构建的项目,可以选
esnext,打包器会自己再处理模块语法,最终产出浏览器能直接执行的 bundle。
moduleResolution 则是 TypeScript 编译器解析模块路径时用的算法。它决定了 import { add } from './math' 里的 ./math 解析到哪个文件。常见取值对应关系如下:
| module 取值 | 推荐的 moduleResolution | 说明 |
|---|---|---|
commonjs |
node10(旧版叫 node) |
按 Node 的模块查找规则:先找 ./math.ts,没有就找 ./math/index.ts,再找 node_modules |
esnext / es2015 |
bundler 或 node10 |
bundler 是 TS 5.0 新增的,匹配 Vite/Webpack 等打包器的解析规则 |
esnext |
node16 / nodenext |
用于 Node.js 的 ESM 模式(package.json 里 "type": "module") |
这里最典型的一个坑,我见太多人踩过:项目用 Vite + TS,module 设置成了 esnext,moduleResolution 却保持了默认的 node10(老的 node 模式),结果 import { someModule } from '@/utils/format' 这种路径别名怎么都报红。原因就是 node10 模式不认识路径别名,必须额外配置 baseUrl 和 paths,或者干脆把 moduleResolution 改成 bundler。
json复制{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
再说一个容易忽略的点:package.json 里 "type": "module" 会影响 Node 执行 .js 文件时对模块语法的解释。如果你的 TS 编译产物是 CommonJS(module: "commonjs"),但 package.json 写了 "type": "module",Node 会把 .js 文件当 ESM 解析,require is not defined 的报错就来了。这是个极其隐蔽的运行时错误,TS 编译阶段完全不会提示。
结合我自己的经验,项目组在选型时最好定一个统一约定:
- 前端项目(Vite / Webpack):
module: "ESNext"+moduleResolution: "Bundler",配合打包器做最终产物处理。 - Node 后端(CommonJS 体系):
module: "CommonJS"+moduleResolution: "Node10"。 - Node 后端(ESM 体系):
module: "NodeNext"+moduleResolution: "NodeNext",同时保证 package.json 里"type": "module"。
这三套组合是经过大量社区实践验证的,直接照抄没问题。
3. 类型层面的模块化:namespace、type-only import 和 declare 的使用边界
TS 模块化有个区别于普通 JavaScript 的重要维度——类型也是可以模块化的。类型和值在 TS 里是两条不同的流动通道,这条线理不顺,就会出现"运行时性能没问题但编译超时"或者"类型文件乱成一团"的现象。
3.1 export type 和 import type:隔离类型与值的依赖
很多老代码习惯这样写:
typescript复制// types.ts
export interface User {
id: number;
name: string;
}
export function createUser(name: string): User {
return { id: Date.now(), name };
}
typescript复制// service.ts
import { User, createUser } from './types';
const user: User = createUser('张三');
这种写法功能上没问题,但当 service.ts 只用到 User 类型、没用到 createUser 函数时,编译产物里 import 语句会被原样保留(或者被转换成 require),因为它不知道你 import 的到底是值还是类型。
TS 3.8 引入了 import type 语法,专门解决这个问题:
typescript复制// service.ts
import type { User } from './types';
import { createUser } from './types';
const user: User = createUser('张三');
如果只 import 类型,编译产物里这条 import 语句会被完全擦除,不生成任何运行时依赖。这在某些场景下还能顺带打破循环依赖——A 文件 import B 的类型、B 文件 import A 的值,用普通 import 会报循环依赖,但如果 A 只用 B 的类型,改成 import type 就不会在运行时产生实际的 require 关系,编译器也允许这种循环。
使用 isolatedModules: true 的工程里,这个边界尤其重要。esbuild 这类转译器是单文件编译的,它无法像 tsc 那样分析"这个导入到底被当成类型用还是值用"——如果代码里 import { User, createUser } from './types',esbuild 会无条件把两个都编译进产物。只有当你在 types.ts 里写了 export type { User },把它明确声明为纯类型导出,esbuild 才能安全地擦除它。所以新项目里我强烈建议开启 isolatedModules,并养成导出时区分类型和值的习惯:
typescript复制// types.ts
interface UserInternal {
id: number;
name: string;
}
// 明确标记为纯类型导出
export type { UserInternal as User };
3.2 namespace:什么年代了,还要不要用
namespace 是 TS 早期模仿"内部模块"的概念设计出来的,它把类型和值封装在一个命名空间里。现在回头看,namespace 对于现代项目来说基本算是历史包袱,但是在某些场景下它仍然有不可替代的价值。
typescript复制export namespace API {
export interface Request {
url: string;
method: 'GET' | 'POST';
}
export interface Response<T> {
code: number;
data: T;
}
}
上面的写法在声明文件(.d.ts)里非常常见——它允许你导出一组相关的类型,而不需要拆成多个文件再逐个 import。但对于业务代码来说,我个人的建议是:别用 namespace 组织业务模块。因为 namespace 会让类型查找变得隐式化,IDE 的自动补全和跳转体验都会打折,而且和 ESM 的显式 import 风格格格不入。除非你在写 .d.ts 声明文件、要给 umd 库提供类型定义,否则遇到 namespace 就尽量绕开。
那什么时候用 namespace 是明智的?写全局类型扩展的时候。比如你想在 Window 上挂一个自定义属性:
typescript复制// global.d.ts
export {};
declare global {
interface Window {
__APP_CONFIG__: {
apiBaseUrl: string;
env: 'dev' | 'prod';
};
}
}
注意这里用了 declare global,不是 namespace 本身,但这属于"全局类型模块化"的范畴——通过 declare 告诉 TS 这是一个全局声明块,所有模块都能读取 window.__APP_CONFIG__ 的类型,而不用显式 import。这是做全局配置、环境变量类型注入的常用手段。
3.3 keyof typeof 组合拳:从对象字面量反推类型的经典套路
搜索热度里出现了 ts keyof typeof,这确实是一个高频且极其实用的技巧。它的本质是把"值空间"映射到"类型空间"。
typescript复制const ERROR_MESSAGES = {
NOT_FOUND: '资源不存在',
UNAUTHORIZED: '请先登录',
FORBIDDEN: '没有操作权限',
} as const;
type ErrorCode = keyof typeof ERROR_MESSAGES;
// 等价于 type ErrorCode = 'NOT_FOUND' | 'UNAUTHORIZED' | 'FORBIDDEN'
这里的 typeof ERROR_MESSAGES 是 TS 类型操作符(不是 JS 的 typeof),它把一个常量对象的值提升为类型。as const 则是为了让 NOT_FOUND 被推断为字符串字面量类型 'NOT_FOUND' 而不是宽泛的 string。
这种写法最常见的应用是配置表、枚举表、映射表。它保证了你新增一个配置项,类型系统会自动收窄,所有相关函数就能在编译期感知到遗漏的 switch case 或者 if else 分支,而不是运行到大半夜线上才报警。
typescript复制// 结合 Record 做类型安全映射
const MODULE_MAP = {
user: () => import('./modules/user'),
order: () => import('./modules/order'),
stats: () => import('./modules/stats'),
} as const;
type ModuleKey = keyof typeof MODULE_MAP;
async function loadModule(key: ModuleKey) {
const loader = MODULE_MAP[key];
return loader();
}
这样写的好处是,你删掉 stats 配置时,所有把字符串 'stats' 传进来的调用点全部编译报错,重构起来极其放心。
4. 路径别名和模块边界设计:让项目的依赖方向变得清晰
模块化做到中后期,真正决定代码质量的已经不是某一个文件的 import 写法,而是整个项目的模块依赖方向。依赖方向乱的代码,哪怕每个文件都写得再优雅,整体也是一团乱麻。
4.1 路径别名:收编"../../../"这种魔鬼路径
Vite + TS 项目里最常见的路径别名配置我上面已经给过。再补充一个容易被忽略的坑:tsconfig.json 里配了 paths 之后,还需要确保打包器的别名配置和它保持一致。Vite 需要在 vite.config.ts 里用 resolve.alias 同步配置。
typescript复制// vite.config.ts
import { defineConfig } from 'vite';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});
Webpack 项目则是在 resolve.alias 里配。项目里最容易出现的问题就是 tsconfig 配了、打包器没配,结果 IDE 里类型检查不报错,一跑 vite build 各种 Module not found。而且这种错只在独立入口文件里报,极其难定位。
4.2 分层依赖:我项目里用的模块组织方式
我给团队定的项目模块结构一般是这样分的:
code复制src/
├── app/ # 应用层:路由、布局、根组件
├── pages/ # 页面级组件,只负责组合
├── features/ # 业务功能模块,按领域划分
│ ├── auth/
│ ├── order/
│ └── user/
├── shared/ # 跨业务共享的组件、工具、类型
│ ├── components/
│ ├── hooks/
│ ├── utils/
│ └── types/
└── api/ # 接口请求层
依赖规则只有一条:上层可以依赖下层,下层绝对不能依赖上层。
features/里的模块可以依赖shared/和api/,但shared/不能 importfeatures/里的东西。api/是独立的,不依赖任何业务模块。pages/是组合层,负责把features/的模块拼装成页面,但pages/之间尽量不互相依赖。
这条规则靠什么约束?靠 lint 工具。eslint-plugin-boundaries 或者 eslint-plugin-import 的 no-restricted-paths 规则都能做模块边界校验。我实际用的是 eslint-plugin-boundaries,它可以根据 tsconfig 的 paths 配置来区分模块层级:
json复制{
"rules": {
"boundaries/element-types": [2, {
"default": "disallow",
"rules": [
{ "from": "app", "allow": ["pages", "features", "shared", "api"] },
{ "from": "pages", "allow": ["features", "shared", "api"] },
{ "from": "features", "allow": ["shared", "api"] },
{ "from": "shared", "allow": ["shared"] },
{ "from": "api", "allow": ["shared"] }
]
}]
}
}
配置完代码里一旦出现 shared/ 反向依赖 features/,CI 阶段直接报红,比 code review 阶段口头强调有用得多。
4.3 Barrel 文件和循环依赖的取舍
Barrel 文件(桶文件)就是目录下的 index.ts,统一导出一整个目录的内容:
typescript复制// features/user/index.ts
export * from './types';
export * from './api';
export * from './components/UserList';
很多人喜欢用 barrel 简化外部引用路径,好处是明显的——外部只需要 import { UserList } from '@/features/user',不需要知道文件具体在哪个子目录。但 barrel 文件用多了也有副作用,主要集中在两个地方:
- Tree Shaking 失效风险:
export *会把模块里所有导出都暴露出来,如果内部模块依赖关系复杂,打包器可能无法正确清理未用代码。 - 循环依赖更容易被触发:barrel 把所有子模块聚合成一个大入口,两个 barrel 之间一旦存在交叉引用,很容易在初始化阶段形成"鸡生蛋蛋生鸡"的问题。
所以我的建议是:barrel 文件只用于"对外统一暴露"的场景(比如一个 npm 包的入口、一个独立 feature 的对外接口),不要在内部层级密集使用。internal 模块之间的引用,直接用相对路径指向具体文件,让依赖关系保持清晰直观。显式永远好过隐式,这是模块化设计里最朴素也最重要的一条原则。
5. 实战案例:封装一个类型安全的 axios 请求模块
搜索热词里出现"ts 封装 axios 设置响应返回类型",这确实是最适合演示"类型模块化+值模块化"结合的项目。我拿一个实际封装过的例子展开讲。
先定义接口层的数据结构:
typescript复制// shared/types/http.ts
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
}
export interface PageResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
// 业务通用分页参数
export interface PageParams {
page: number;
pageSize: number;
}
然后封装请求函数的核心。这里最值得注意的设计是如何让请求方法的返回类型自动携带泛型信息。如果不做处理,request.get('/api/user') 返回的类型就是 Promise<AxiosResponse<any>>,业务方拿到的 data 还得手动断言,不优雅也不安全。
typescript复制// shared/utils/http.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import type { ApiResponse } from '@/shared/types/http';
// 创建一个独立的 axios 实例
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
});
// 响应拦截器:剥掉 axios 的响应壳,取出后端统一包装的数据体
http.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { code, message, data } = response.data;
if (code !== 0) {
// 业务错误统一抛异常
return Promise.reject(new Error(message));
}
return data as any; // 这里先放行,交给泛型函数收口
},
(error) => {
// 网络错误、超时等统一处理
return Promise.reject(error);
}
);
接下来是泛型封装的关键。这里我有一个习惯:暴露的业务请求方法,用泛型参数 T 表示期望返回的 data 类型,并且通过重载让调用方可以省略显式传泛型。
typescript复制// shared/utils/http.ts
export function get<T = unknown>(
url: string,
params?: Record<string, unknown>,
config?: AxiosRequestConfig
): Promise<T> {
return http.get(url, { params, ...config }) as Promise<T>;
}
export function post<T = unknown>(
url: string,
body?: unknown,
config?: AxiosRequestConfig
): Promise<T> {
return http.post(url, body, config) as Promise<T>;
}
export function put<T = unknown>(
url: string,
body?: unknown,
config?: AxiosRequestConfig
): Promise<T> {
return http.put(url, body, config) as Promise<T>;
}
export function del<T = unknown>(
url: string,
config?: AxiosRequestConfig
): Promise<T> {
return http.delete(url, config) as Promise<T>;
}
注意看,这里 Promise<T> 是把自己对后端的约定"接口返回的 data 字段就是业务数据本体"固化在了类型里。响应拦截器剥壳后返回的是 data,所以泛型 T 直接对应业务数据类型。
业务模块里就可以这么用:
typescript复制// features/user/api.ts
import { get, post } from '@/shared/utils/http';
import type { ApiResponse, PageResult } from '@/shared/types/http';
export interface UserInfo {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
// 只导出一个模拟的小方法,顺带演示 PageResult 用法
export function getUserList(page: number, pageSize: number) {
return get<PageResult<UserInfo>>('/api/user/list', { page, pageSize });
}
export function createUser(data: Pick<UserInfo, 'name' | 'email' | 'role'>) {
return post<UserInfo>('/api/user', data);
}
此时业务调用方的类型推导是完整的:
typescript复制// features/user/components/UserTable.tsx
import { getUserList } from '../api';
async function load() {
const result = await getUserList(1, 20);
result.total; // 类型是 number
result.list; // 类型是 UserInfo[]
// result.list[0].role; // 'admin' | 'user'
}
整个链路下来,类型信息从后端接口定义的 moment 开始就贯穿到了 UI 层,中间没有任何 any 或者是断言。把代码库从 this 风格改造到这个程度之后,你会明显感觉到重构时编译器能帮你找出所有遗漏的字段变更点。这是我个人在实际项目里收益最大的一次重构,强烈推荐你也做一次。
6. 高频报错排查指南:模块化相关的几个经典报错
模块化配置和写法在真实工程里最常见的就是下面几个报错。我按排查顺序列一遍,方便你在搜索引擎里遇到直接对号入座。
6.1 "Cannot find module './xxx' or its corresponding type declarations"
这个报错是所有 TS 模块化难题里出现频率最高的。常见成因有三类:
- 文件后缀问题。TS 不识别
.vue、.css、.png这类非 JS/TS 文件,需要在env.d.ts里补声明,否则 import 进来直接找不到:
typescript复制// env.d.ts
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}
declare module '*.css';
declare module '*.png';
-
路径别名没配全。tsconfig 里配了
paths,但 Vite/Webpack 里没有同步 alias,或者 IDE 用的 tsconfig 和编译用的 tsconfig 不是同一个。这种情况 IDE 里不报错,但命令行编译和构建时错乱。 -
模块解析算法不匹配。
moduleResolution选错,导致.ts后缀自动补全规则不一致。比如node16模式下,相对路径 import 必须带显式后缀./math.js(编译后的 JS 后缀),而源码里写./math.ts反而报错。这就是 TS 的 NodeNext 解析规则——它在类型层面要求你按"编译后文件"的扩展名来写 import。很多从bundler切换过来的人死在这一步。
6.2 "Module has no exported member 'xxx'"
这个报错大多是三种情形:
- 类型导出命名不一致。在
types.ts里导出的接口叫IUser,业务方 import 的却是User。这种由于命名风格混乱导致的报错,最好靠import/export的显式命名解决,而不是去给每个类型整别名。 - 导出的值没加
export关键字。只有export出来的成员才能被外部 import。 - 碰到循环依赖时的"幽灵报错"——一个模块的初始化还没完成,另一个模块尝试读取它的导出。
6.3 循环依赖导致的运行时错误
循环依赖是模块化工程里最隐蔽的雷。它的典型表现是:控制台报 Cannot access 'xxx' before initialization,但代码里完全看不出有什么异步问题。
看这个例子:
typescript复制// a.ts
import { b } from './b';
export const a = 'a';
export function getA() {
console.log(b);
}
typescript复制// b.ts
import { getA } from './a';
export const b = 'b';
入口加载顺序如果是 a.ts → b.ts → a.ts,在 b.ts 执行到 import { getA } from './a' 时,a.ts 可能还没执行完,getA 尚未被绑定,于是 b.ts 拿到一个 undefined 引用。等到真正运行时调用 getA(),就会报错。
排查循环依赖的手段,我常用 madge 这个工具直接在 CI 里跑:
bash复制npx madge --circular --extensions ts src
如果输出里列出了 a.ts → b.ts → a.ts 这样的链路,那就说明循环依赖产生了。解决循环依赖最简单可靠的办法是使用 import type 打破类型层面的循环,或者把公共依赖下沉到独立模块里。比如上面的例子,把 getA 用到的 b 移动到公共模块 constants.ts,a 和 b 都从 constants 导入,依赖环就解开了。
6.4 编译产物里出现不必要的 require/import
把 isolatedModules: true 打开后,TS 编译器不再做全局语义检查,转译器才能安全彻底地删除纯类型导入。如果项目构建链路上用了 esbuild 这类的单文件转译工具(Vite 生产构建底层就是 Rollup + esbuild,Babel 的用户同理),建议务必开启这个选项,让"纯类型引用"和"值引用"的边界在代码里显式表达出来。
7. 我目前在用的推荐配置和实践清单
文章最后,把我踩过无数坑之后沉淀下来的"模块化标准配置"完整列出来,供你直接参考。
7.1 tsconfig 的核心配置片段
json复制{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
需要说明的是,esModuleInterop 也是一个跟模块化强相关且容易误解的选项。它解决的是 CommonJS 和 ESM 互操作时的默认导出问题——没有它,import express from 'express' 会报错,必须写成 import * as express from 'express'。开启之后,TS 编译器会生成 __importDefault 辅助函数来兼容。新项目请务必开启,基于 CJS 的老库(比如大量 Node 工具链)在 ESM 工程里也要靠它才能正常导入。
7.2 模块设计的"六条军规"
把下面的清单贴到项目 README 顶部,比在代码评审时逐条提醒高效十倍:
- 一个模块只干一件事,一个文件尽量只导出一个主要成员。
- 类型导出用
export type,类型导入用import type,从语法上区分值和类型。 - 内部模块引用用相对路径并用显式文件路径,对外暴露接口用 barrel 文件(
index.ts)。 - 不允许
shared层反向依赖业务features层,用 eslint 规则强制。 - 每个模块的公共 API 必须是"窄口"——尽量不导出非必需的内部实现(比如不要在公共导出里暴露内部 helper 函数),这样才能在后续演进中保留改写空间。
- 配置别名时,tsconfig、打包器、jest/vitest 三套配置必须同步,建议用统一配置文件生成,避免手工维护三处。
7.3 ts 文件乱码成 ts 分片的问题
搜索热词里有个相对奇特的"下载视频变成了很多ts文件",这儿也顺手澄清一下。这个 ts 是 MPEG Transport Stream(TS 视频流格式)的分片文件,和 TypeScript 完全无关。通常出现在 HTTP 直播流(HLS)协议里,视频被切成一段段 .ts 分片,播完后浏览器或播放器会按顺序加载合并。遇到这种情况,可以直接用类似 ffmpeg -i "playlist.m3u8" -c copy output.mp4 的命令把分片合并成完整视频文件。这既不是代码问题,也不是模块化问题,属于媒体处理范畴,但既然热词里出现了,我顺手提一嘴,免得有人被误导。
7.4 关于"基于 Maven 构建的 SpringMVC 模块化项目 通过 IDEA 配置 Tomcat 容器启动"这类热搜
还有一条热搜跟 TS 模块化关系不大,但挺有意思——“基于 Maven 构建的 SpringMVC 模块化项目 通过 IDEA 配置 Tomcat 容器启动”。Java 世界的模块化比前端更老辣,Maven 的一个多模块工程天然就有 parent、modules、dependencyManagement 的依赖关系治理机制。IDEA 配置 Tomcat 启动多模块项目,核心步骤无非是:先在 root pom 里用 <modules> 聚合子模块,子模块之间用 <dependency> 声明依赖,再在 IDEA 的 Run Configuration 里选择 Web 子模块的 war exploded,配置 Tomcat 的 deploy 和 application context。这里面最容易踩的坑是"某个子模块没有加入 root pom 的 modules 列表",导致 IDEA 识别不到模块依赖,编译期直接报无法解析符号。本质上,Java 和 TS 在模块化上的诉求非常一致——管理依赖边界、显式声明关系、让编译工具能全量感知。理解了这一点,跨语言看项目结构会通透很多。
这篇文章从 TS 模块化的原理、配置、实践、报错排查一路写到了相关冷知识。想深入的话,建议你打开自己现在的项目,先跑一遍 npx madge --circular --extensions ts src,然后把项目里的 import type 和 export type 用起来,再动手把所有 ../../../ 的垃圾路径清理成别名。这三件事做完,你会明显感觉到代码库质量的提升,这也是我这几年在模块化治理上收获感最强的一次实践。
