9.7 KiB
代码撰写规范
本规范规定脚手架里新代码怎么放、怎么命名、怎么接进容器。业务数据流见 项目架构。
目录
环境变量
环境文件放在 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:
baseURL: import.meta.env.VITE_API_URL || 'https://mybackend.com'
目录与分层
src/app 放全库能力,不放业务用例。src/features/<name> 放一个功能模块。
| 层 | 放置 | 可以依赖 |
|---|---|---|
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) 注入对应实现。
@bindAs(ISecurityRepository)
@injectable('Singleton')
export class SecurityRepository implements ISecurityRepository {}
只在 infra 的接口实现上使用 @bindAs。用例和 ViewModel 由 autobind 按类本身注册,不再包一层接口。
功能模块入口
src/features/<name>/<name>.ts 只做副作用 import,不导出类,不写用例。它让文案注册和 @bindAs 在页面渲染前执行。
src/features/security/security.ts:
import './i18n';
import './infra/persistence/security_repository';
第一行执行 registerMessages。第二行使 SecurityRepository 上的 @bindAs(ISecurityRepository) 跑起来。安全页在文件顶部引用它:
import '../../security';
project.ts 导入本地仓储和远程仓储。使用项目用例的页面同样要先引用它,例如 import '@/features/project/project' 或 import '../../project'。
新增仓储或文案包时,把对应文件 import 进这个入口,并保证至少有一个会被路由加载到的页面引用该入口。没有这行 import,容器里没有实现,文案也不会注册。
import
跨目录使用 @/,与 tsconfig.app.json 的 paths 一致。同一功能模块内部使用相对路径。
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。
新增功能模块
- 在
src/features/<name>/domain写实体和接口。接口是I前缀的抽象类。 - 在
application写用例,并标@injectable('Singleton')。 - 在
infra写实现,标@injectable('Singleton')和@bindAs(接口)。 - 把实现 import 进
src/features/<name>/<name>.ts。有文案时,同时 import 该模块的i18n。 - 在
presentation写页面和 ViewModel。ViewModel 继承PageViewModel,标@injectable('Transient')。页面先 import 模块入口,再用useViewModel取出 ViewModel。页面默认导出。 - 在
src/app/routes.ts登记路径。需要登录的页面放进auth布局。