# 代码撰写规范 本规范规定脚手架里新代码怎么放、怎么命名、怎么接进容器。业务数据流见 [项目架构](./项目架构.md)。 ## 目录 - [环境变量](#环境变量) - [目录与分层](#目录与分层) - [文件命名](#文件命名) - [类与函数命名](#类与函数命名) - [依赖注入](#依赖注入) - [Singleton 与 Transient](#singleton-与-transient) - [功能模块入口](#功能模块入口) - [import](#import) - [新增功能模块](#新增功能模块) ## 环境变量 环境文件放在 `src/`。`vite.config.ts` 的 `envDir` 是 `src`。 | 文件 | 脚本 | | ---------------------- | ----------------------------------- | | `src/.env.debug` | `npm run debug` | | `src/.env.development` | `npm run dev`、`npm run build` | | `src/.env.production` | `npm run prod`、`npm run build:prod` | 只有 `VITE_` 开头的变量会进入浏览器。命名是 `VITE_` 加大写蛇形,例如 `VITE_API_URL`。 这里只放会公开到前端的地址和开关。密钥不放进这些文件。业务代码用 `import.meta.env.VITE_*` 读取,并在使用处给出缺省值。`Http` 的基地址写在 `src/app/network/http.ts`: ```ts baseURL: import.meta.env.VITE_API_URL || 'https://mybackend.com' ``` ## 目录与分层 `src/app` 放全库能力,不放业务用例。`src/features/` 放一个功能模块。 | 层 | 放置 | 可以依赖 | | -------------- | ----------------- | -------------------------------------- | | `domain` | 聚合、值对象、领域事件、接口 | 本模块 `domain` | | `application` | 用例 | 本模块 `domain`,以及 `src/app` 里该用例需要的入口 | | `infra` | 接口的实现 | 本模块 `domain`,以及 `src/app` 的网络、数据库、依赖注入 | | `presentation` | 页面、ViewModel、展示组件 | 本模块 `application`,以及标明可使用的其他模块类型 | `domain` 不引用 React,也不引用 `src/app`。没有内容的层不建空目录。 聚合根是外部唯一可以修改的实体。`Project` 包含 `Document` 和图形,落笔是否成立、撤销和重做都写在 `Project` 上。页面和其他模块不调用 `Document.addShape`,也不替换文档快照。 仓储接口只表达取出和保存聚合,不表达本地或远程。`local` 与 `remote` 留在 `application`,用来选择哪个 `infra` 实现。接口的返回值是领域对象。登录返回 `User`,不返回未标明类型的 `user`。 登录成功先形成领域结果,再由用例调用 `TokenStore.save`。`AUTH_CHANGED` 只通知界面,不是领域事件。 路由只在 `src/app/routes.ts` 里把 URL 指到 `presentation` 下的页面。需要登录的页面放进 `auth` 布局,仅访客可进的页面放进 `guest` 布局。 ## 文件命名 目录和文件用小写蛇形。 | 种类 | 形式 | 例子 | | --------- | ---------------------------------------- | --------------------------------- | | 页面 | 与所在目录同名 | `home/home.tsx` | | ViewModel | `*_view_model.ts` | `security_view_model.ts` | | 样式 | 与组件同名的 `.scss` | `home.scss` | | 实体 | 聚合或业务名文件 | `project.ts`、`document.ts`、`user.ts` | | 抽象接口 | `i_` 前缀 | `i_security_repository.ts` | | 仓储实现 | `*_repository.ts`,放在 `infra/persistence` | `security_repository.ts` | | 布局 | 点号标出角色 | `auth.layout.tsx` | | 文案 | 语言码 | `zh_cn.ts`、`en_us.ts` | | 模块入口 | 与功能目录同名 | `security.ts`、`project.ts` | ## 类与函数命名 类用帕斯卡命名。函数和钩子用小驼峰,钩子以 `use` 开头。 | 种类 | 形式 | 例子 | | ---- | -------------------------------- | --------------------------- | | 用例 | `名称Application` | `SecurityApplication` | | 页面模型 | `名称ViewModel`,继承 `PageViewModel` | `HomeViewModel` | | 仓储实现 | `名称Repository` | `LocalProjectRepository` | | 接口 | 抽象类,`I` 前缀 | `ISecurityRepository` | | 实体 | 业务名,不加 `Entity` 后缀 | `Project`、`User`、`AuthCredentials` | | 枚举 | `名称Enum` | `AuthEnum` | 实体使用 `User`、`Project` 这样的业务名,类名不带 `Entity` 后缀。 路由页面使用默认导出。`src/app/routes.ts` 按文件加载页面。 ## 依赖注入 全库只有 `src/app/di/root_container.ts` 里的 `rootContainer`。容器打开 `autobind`,默认 scope 是 `Singleton`。 | 对象 | 写法 | | --------------------------------- | ------------------------------------------- | | 用例、`TokenStore`、`Http`、`Database` | `@injectable('Singleton')` | | 页面 ViewModel | `@injectable('Transient')` | | 接口实现 | `@injectable('Singleton')`,并加 `@bindAs(接口)` | ### Singleton 与 Transient `Singleton` 在整个应用里只创建一次。之后无论哪个页面、哪次 `rootContainer.get`,拿到的都是同一个实例。适合没有页面现场、需要一直留着的对象:用例、仓储、`TokenStore`、`Http`、`Database`。登录后的凭证、数据库连接和用例方法要在首页、编辑器之间共用,所以这些类用 `Singleton`。 `Transient` 每次 `rootContainer.get` 都新建一个实例。`useViewModel` 在页面挂载时取一次 ViewModel,离开页面后这次实例不再使用。适合只属于当前页的状态:表单、`errorMessage`、尚未落笔的草稿。撤销栈属于 `Project`,不放在 ViewModel 里。`SecurityViewModel`、`HomeViewModel`、`EditorViewModel`、`ProjectListViewModel` 都用 `Transient`。 ViewModel 若标成 `Singleton`,再次进入同一页会看到上次留下的表单、错误和草稿。用例若标成 `Transient`,每个页面都会各持有一份,凭证和仓储无法在页面之间共用。 无参的 `@injectable()` 会落到容器默认的单例,效果与 `Singleton` 相同。新代码写出 scope。现有仓储仍是无参写法。 `bindAs` 定义在 `src/app/di/bind_as.ts`。装饰器在类定义时绑定,并且写在 `@injectable` 上面。类装饰器从靠近类的那一个开始执行,`@injectable` 先写入构造参数类型,`bind(port).to(impl)` 才能读到。同一个实现再次执行时,先卸掉上一次的绑定再绑。同一个接口有多个实现时传入名称,例如 `@bindAs(IProjectRepository, ProjectStore.LOCAL)`。用例构造函数用 `@inject(IProjectRepository)` 和 `@named(ProjectStore.LOCAL)` 注入对应实现。 ```ts @bindAs(ISecurityRepository) @injectable('Singleton') export class SecurityRepository implements ISecurityRepository {} ``` 只在 `infra` 的接口实现上使用 `@bindAs`。用例和 ViewModel 由 `autobind` 按类本身注册,不再包一层接口。 ## 功能模块入口 `src/features//.ts` 只做副作用 import,不导出类,不写用例。它让文案注册和 `@bindAs` 在页面渲染前执行。 `src/features/security/security.ts`: ```ts import './i18n'; import './infra/persistence/security_repository'; ``` 第一行执行 `registerMessages`。第二行使 `SecurityRepository` 上的 `@bindAs(ISecurityRepository)` 跑起来。安全页在文件顶部引用它: ```ts import '../../security'; ``` `project.ts` 导入本地仓储和远程仓储。使用项目用例的页面同样要先引用它,例如 `import '@/features/project/project'` 或 `import '../../project'`。 新增仓储或文案包时,把对应文件 import 进这个入口,并保证至少有一个会被路由加载到的页面引用该入口。没有这行 import,容器里没有实现,文案也不会注册。 ## import 跨目录使用 `@/`,与 `tsconfig.app.json` 的 `paths` 一致。同一功能模块内部使用相对路径。 ```ts import { useViewModel } from '@/app/lifecycle/use_view_model'; import { ProjectApplication } from '@/features/project/application/project_application'; import { SecurityViewModel } from './security_view_model'; ``` 副作用 import 放在文件最上方,单独成行,不写绑定。类型使用 `import type`。 其他模块可以引用对方的 application,以及标明可使用的 entity 和展示组件。不引用对方的 `infra`。 ## 新增功能模块 1. 在 `src/features//domain` 写实体和接口。接口是 `I` 前缀的抽象类。 2. 在 `application` 写用例,并标 `@injectable('Singleton')`。 3. 在 `infra` 写实现,标 `@injectable('Singleton')` 和 `@bindAs(接口)`。 4. 把实现 import 进 `src/features//.ts`。有文案时,同时 import 该模块的 `i18n`。 5. 在 `presentation` 写页面和 ViewModel。ViewModel 继承 `PageViewModel`,标 `@injectable('Transient')`。页面先 import 模块入口,再用 `useViewModel` 取出 ViewModel。页面默认导出。 6. 在 `src/app/routes.ts` 登记路径。需要登录的页面放进 `auth` 布局。