项目结构

VextJS 遵循 约定优于配置 的设计理念,通过固定的目录结构实现自动扫描、加载和注册,无需手动配置路由映射或服务注入。

标准目录结构

my-app/
├── src/
   ├── config/                # 配置文件(必须)
   ├── default.ts         # 默认配置(必须存在)
   ├── development.ts     # 开发环境覆盖(可选)
   ├── production.ts      # 生产环境覆盖(可选)
   └── local.ts           # 本地覆盖,不提交到 Git(可选)

   ├── routes/                # 路由定义(约定式,自动扫描)
   ├── index.ts           # → /
   ├── users.ts           # → /users
   ├── users/
   ├── index.ts       # → /users(与 users.ts 二选一)
   └── [id].ts        # → /users/:id(动态参数)
   └── admin/
       ├── index.ts       # → /admin
       └── settings.ts    # → /admin/settings

   ├── services/              # 服务层(约定式,自动扫描 + 注入)
   ├── user.ts            # → app.services.user
   ├── order.ts           # → app.services.order
   └── payment/
       └── stripe.ts      # → app.services.payment.stripe

   ├── middlewares/            # 中间件定义(约定式,自动扫描)
   ├── auth.ts            # → 通过 name 'auth' 引用
   └── check-role.ts      # → 通过 name 'check-role' 引用

   ├── plugins/               # 插件(约定式,自动扫描)
   ├── redis.ts           # 自定义插件
   └── sentry.ts          # 自定义插件

   └── locales/               # 国际化语言包(可选)
       ├── zh-CN.ts           # 中文语言包
       └── en-US.ts           # 英文语言包

├── dist/                      # 构建产物(vext build 生成)
├── package.json
└── tsconfig.json              # TypeScript 配置

各目录详解

src/config/ — 配置目录

框架启动时,config-loader 按以下顺序加载配置文件并深度合并:

框架内置默认值 → default.ts → {NODE_ENV}.ts → local.ts
文件用途是否必须
default.ts所有环境的基础配置✅ 必须
development.ts开发环境覆盖可选
production.ts生产环境覆盖可选
test.ts测试环境覆盖可选
local.ts本地开发覆盖(应加入 .gitignore可选

环境文件通过 NODE_ENV 环境变量自动匹配。例如 NODE_ENV=production 时加载 production.ts

// src/config/default.ts
export default {
  port: 3000,
  host: '0.0.0.0',
  logger: { level: 'info' },
  openapi: { enabled: true },
};
// src/config/production.ts — 仅覆盖需要变更的字段
export default {
  logger: { level: 'warn' },
  openapi: { enabled: false },
};
合并策略

配置采用深度合并(deep merge),你只需在环境文件中声明需要覆盖的字段。middlewares 数组使用智能 patch 策略(按 name 匹配并覆盖),而非简单的数组替换。

src/routes/ — 路由目录

路由文件由 router-loader 自动扫描,文件路径直接映射为 URL 前缀。每个文件使用 defineRoutes() 导出路由定义。

路径映射规则

文件路径URL 前缀说明
routes/index.ts/根路由
routes/users.ts/users一级路由
routes/users/index.ts/users等同于 users.ts
routes/users/[id].ts/users/:id动态参数
routes/admin/settings.ts/admin/settings嵌套路由

动态参数

使用 [paramName] 语法表示动态路由参数,加载时自动转换为 :paramName

routes/users/[id].ts        → /users/:id
routes/posts/[slug]/comments.ts → /posts/:slug/comments

文件内的子路由

每个文件内部可以注册多个子路由。路径会自动拼接文件级前缀:

// src/routes/users.ts → 前缀 /users
import { defineRoutes } from 'vextjs';

export default defineRoutes((app) => {
  // GET /users/list
  app.get('/list', async (req, res) => {
    const users = await app.services.user.findAll();
    res.json(users);
  });

  // POST /users(子路径为 / 时与前缀合并)
  app.post('/', async (req, res) => {
    const user = await app.services.user.create(req.body);
    res.json(user, 201);
  });

  // GET /users/:id
  app.get('/:id', async (req, res) => {
    const user = await app.services.user.findById(req.params.id);
    res.json(user);
  });
});

排除规则

以下文件会被自动跳过,不作为路由加载:

  • 测试文件:*.test.ts*.spec.ts
  • _. 开头的文件/目录
  • node_modules 目录

src/services/ — 服务目录

服务文件由 service-loader 自动扫描,每个文件导出一个 class,构造函数接收 app 参数。实例化后自动挂载到 app.services

命名映射规则

文件路径访问方式说明
services/user.tsapp.services.user扁平命名
services/order.tsapp.services.order扁平命名
services/payment/stripe.tsapp.services.payment.stripe嵌套命名
services/user-profile.tsapp.services.userProfilekebab → camelCase

文件名自动从 kebab-case 转换为 camelCase。子目录会映射为嵌套对象。

服务类写法

// src/services/user.ts
import type { VextApp } from 'vextjs';

export default class UserService {
  private app: VextApp;

  constructor(app: VextApp) {
    this.app = app;
  }

  async findAll() {
    // 业务逻辑...
    return [];
  }

  async findById(id: string) {
    // 可以访问其他 service
    // const profile = await this.app.services.userProfile.get(id);
    return { id, name: 'Alice' };
  }

  async create(data: unknown) {
    this.app.logger.info({ data }, 'Creating user');
    return { id: '1', ...data as object };
  }
}
循环依赖检测

service-loader 内置循环依赖检测机制。如果 ServiceA 在构造函数中直接访问 app.services.b,而 ServiceB 也访问 app.services.a,框架会在启动时检测到并报错。

推荐做法:在构造函数中只保存 app 引用,在方法中按需访问其他 service(延迟访问)。

src/middlewares/ — 中间件目录

中间件文件由 middleware-loader 自动扫描。每个文件导出一个通过 defineMiddlewaredefineMiddlewareFactory 标记的中间件。

文件名即中间件名,在配置和路由中通过名称引用:

// src/middlewares/auth.ts
import { defineMiddleware } from 'vextjs';

export default defineMiddleware(async (req, res, next) => {
  const token = req.headers['authorization'];
  if (!token) req.app.throw(401, 'Unauthorized');
  // ... 验证 token
  await next();
});

使用时,先在配置中声明白名单,然后在路由中引用:

// src/config/default.ts
export default {
  middlewares: [
    'auth',                                          // 普通中间件
    { name: 'check-role', options: { roles: ['admin'] } },  // 工厂中间件 + 默认参数
  ],
};
// src/routes/admin.ts — 路由中引用
app.get('/dashboard', {
  middlewares: ['auth', 'check-role'],
}, handler);

详见 中间件 章节。

src/plugins/ — 插件目录

插件文件由 plugin-loader 自动扫描,按 dependencies 声明进行拓扑排序后依次执行 setup()

// src/plugins/redis.ts
import { definePlugin } from 'vextjs';

export default definePlugin({
  name: 'redis',
  async setup(app) {
    const redis = createRedisClient(app.config.redis);
    app.extend('cache', redis);
    app.onClose(() => redis.quit());
  },
});

详见 插件 章节。

src/locales/ — 国际化目录

语言包文件由 i18n-loader 自动扫描,文件名即语言代码。加载后注册到 schema-dsl 的 i18n 系统,与 app.throw() 联动。

// src/locales/zh-CN.ts
export default {
  'user.not_found':       { code: 40001, message: '用户不存在' },
  'balance.insufficient': { code: 20001, message: '余额不足,当前余额 {{balance}}' },
};
// src/locales/en-US.ts
export default {
  'user.not_found':       { code: 40001, message: 'User not found' },
  'balance.insufficient': { code: 20001, message: 'Insufficient balance, current: {{balance}}' },
};

详见 国际化 (i18n) 章节。

自动扫描加载顺序

框架启动时(bootstrap)按以下顺序加载各目录:

1. config/      → 加载并合并配置(loadConfig)
2. locales/     → 加载语言包(loadI18n)
3. plugins/     → 拓扑排序 + 执行 setup()(loadPlugins)
4. middlewares/ → 扫描中间件定义(loadMiddlewares)
5. services/    → 实例化并注入到 app.services(loadServices)
6. routes/      → 扫描路由 + 注册到 adapter(loadRoutes)
7. 启动 HTTP 监听

这个顺序确保:

  • 配置在所有模块之前就绪
  • 插件可以扩展 app 对象(如注入数据库连接)
  • 中间件在路由注册前就绪
  • 服务在路由之前注入,路由 handler 中可以安全访问 app.services

package.json 要求

VextJS 项目必须声明为 ESM 模块:

{
  "type": "module",
  "scripts": {
    "start": "vext start",
    "dev": "vext dev",
    "build": "vext build"
  }
}

tsconfig.json 推荐配置

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

构建产物 dist/

执行 vext build 后,src/ 下的 TypeScript 文件会被编译到 dist/ 目录,保持相同的目录结构。生产模式下(vext start)直接从 dist/ 加载。

dist/
├── config/
│   └── default.js
├── routes/
│   └── index.js
├── services/
│   └── user.js
└── ...

:::tip 开发 vs 生产

  • vext dev:直接从 src/ 加载 .ts 文件(通过 esbuild 即时编译),支持热重载
  • vext start:从 dist/ 加载 .js 文件,需要先执行 vext build :::

下一步

  • 学习 路由 的三段式定义和参数校验
  • 了解 服务层 的设计模式
  • 探索 配置 的完整选项