Add a scaffold script that replaces the bq prefix with the new project name.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
72
doc/基础知识/DDD是什么.md
Normal file
72
doc/基础知识/DDD是什么.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# DDD是什么
|
||||
|
||||
这里的 DDD 指领域规则写在领域对象上,再用分层规定代码放在哪一层。本仓库用它来规定功能模块的写法。
|
||||
|
||||
## 目录
|
||||
|
||||
- [四层](#四层)
|
||||
- [聚合](#聚合)
|
||||
- [端口](#端口)
|
||||
- [本仓库里怎么落](#本仓库里怎么落)
|
||||
|
||||
## 四层
|
||||
|
||||
一个功能模块在 `src/features/<name>/` 下按层分目录。
|
||||
|
||||
| 层 | 放什么 | 不放什么 |
|
||||
|---|---|---|
|
||||
| `domain` | 聚合根、值对象、领域方法、端口 | React、`src/app`、本地或远程的说法 |
|
||||
| `application` | 用例。一次业务动作,例如登录、按 `store` 保存项目 | 页面状态、axios、IndexedDB |
|
||||
| `infra` | 端口的实现。访问网络或本地库,并把接口数据收成领域对象 | 页面和 ViewModel |
|
||||
| `presentation` | 页面和 ViewModel | 领域规则,例如笔画长短、撤销栈 |
|
||||
|
||||
没有内容的层不建空目录。首页只有 `presentation`,因为它不持有自己的用例,列表和新建都调用 `ProjectApplication`。
|
||||
|
||||
用例是模块的业务。`SecurityApplication.login` 负责登录,并把 `LoginResult` 里的访问凭证写入会话。ViewModel 是页面的业务,只保存当前页的表单、错误和加载状态,再调用用例。
|
||||
|
||||
## 聚合
|
||||
|
||||
`Project` 是项目聚合的根。文件:`src/features/project/domain/entities/project.ts`。外部通过它的方法改文档:
|
||||
|
||||
| 方法 | 作用 |
|
||||
|---|---|
|
||||
| `Project.create(name)` | 新建一个项目 |
|
||||
| `addShape(shape)` | 位移小于 2 时拒绝;接受后把当前 `Document` 快照记进聚合,再加入图形 |
|
||||
| `undo()` / `redo()` | 在聚合内部的 `past`、`future` 之间恢复 `Document` |
|
||||
| `touch()` | 更新 `updatedAt`。保存前由用例调用 |
|
||||
|
||||
`Document.addShape` 只由 `Project` 调用。`past` 和 `future` 是聚合上的普通字段,不是 `tProp`,所以 `getSnapshot(project)` 不会把撤销栈写进本地库或接口。
|
||||
|
||||
安全模块的领域对象是 `AuthCredentials`、`RegisterCredentials`、`User` 和 `LoginResult`。用户名不少于 3 个字符,密码不少于 6 个字符,注册时两次密码相同。`User.hasPermission` 判断角色。`LoginResult` 持有 `User` 和 `accessToken`。
|
||||
|
||||
`AUTH_CHANGED` 由 `src/app/session` 在凭证写入或清除后通知界面。它属于会话,不属于领域对象。
|
||||
|
||||
## 端口
|
||||
|
||||
`domain` 里的端口是抽象类。方法名只表达业务,不写 `local` 或 `remote`。
|
||||
|
||||
`ISecurityRepository.login` 返回 `LoginResult`,`register` 接收 `RegisterCredentials`。`SecurityRepository` 用 `Http` 访问接口,再把响应收成 `User` 和 `LoginResult`。
|
||||
|
||||
`IProjectRepository` 声明 `list`、`get`、`save`。`LocalProjectRepository` 和 `RemoteProjectRepository` 都实现这一个端口。存在哪一侧由领域枚举 `ProjectStore` 决定,`Project` 上没有这个字段。
|
||||
|
||||
## 本仓库里怎么落
|
||||
|
||||
登录:
|
||||
|
||||
```text
|
||||
presentation/security_view_model.ts 校验表单,调用 login
|
||||
application/security_application.ts 调用端口,把 accessToken 交给 TokenStore
|
||||
domain/i_security_repository.ts 声明 login,返回 LoginResult
|
||||
infra/security_repository.ts 用 Http 访问 /auth/login,收成 LoginResult
|
||||
```
|
||||
|
||||
绘制:
|
||||
|
||||
```text
|
||||
presentation/editor_view_model.ts 记录草稿,调用 Project.addShape
|
||||
domain/entities/project.ts 判定笔画,维护撤销栈
|
||||
application/project_application.ts 按 store 调用 save
|
||||
infra/..._project_repository.ts 把项目快照写入 IndexedDB 或 Http
|
||||
```
|
||||
|
||||
`TokenStore`、`Http`、`Database` 属于 `src/app`,是全库能力,不是功能模块的领域规则。
|
||||
81
doc/基础知识/DI是什么.md
Normal file
81
doc/基础知识/DI是什么.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# DI是什么
|
||||
|
||||
依赖注入是指类不自己 `new` 依赖,而是由容器按构造函数的参数创建并传入。本仓库的容器是 Inversify 的 `rootContainer`。端口是依赖倒置里的抽象,分层见 [DDD是什么](./DDD是什么.md)。
|
||||
|
||||
## 目录
|
||||
|
||||
- [原则和做法](#原则和做法)
|
||||
- [容器](#容器)
|
||||
- [Singleton 与 Transient](#singleton-与-transient)
|
||||
- [端口怎么绑到实现](#端口怎么绑到实现)
|
||||
- [同一个端口的多个实现](#同一个端口的多个实现)
|
||||
- [装饰器能生效的条件](#装饰器能生效的条件)
|
||||
|
||||
## 原则和做法
|
||||
|
||||
依赖倒置、依赖注入、控制反转是同一条链上的三件事。
|
||||
|
||||
依赖倒置:用例和仓储实现都只对着端口。`SecurityApplication` 调用 `ISecurityRepository.login`,不引用 `Http`。`SecurityRepository` 负责请求。登录成功后,用例把 `accessToken` 写入会话。
|
||||
|
||||
依赖注入:仓储和 `TokenStore` 从构造函数进来。`SecurityApplication` 只声明参数类型。
|
||||
|
||||
控制反转:创建哪一个实现、创建几次,由 `rootContainer` 决定。页面用 `useViewModel` 取出 ViewModel,容器顺着构造函数继续创建用例和仓储。
|
||||
|
||||
## 容器
|
||||
|
||||
`src/app/di/root_container.ts` 创建唯一容器。`autobind` 打开后,标了 `@injectable` 的具体类可以按类本身取出。`defaultScope` 是 `Singleton`。
|
||||
|
||||
页面通过 `useViewModel(SecurityViewModel)` 取出 ViewModel。`useViewModel` 内部调用 `rootContainer.get`。ViewModel 的构造函数需要 `SecurityApplication` 时,容器继续创建或取出用例,再把仓储注入用例。
|
||||
|
||||
## Singleton 与 Transient
|
||||
|
||||
`Singleton` 在整个应用里只有一个实例。用例、仓储、`TokenStore`、`Http`、`Database` 使用它,这样凭证和连接可以在页面之间共用。
|
||||
|
||||
`Transient` 每次 `get` 都新建一个实例。页面 ViewModel 使用它。`useViewModel` 在页面挂载时取一次,离开页面后不再使用这个实例,表单和草稿不会留到下一次进入。
|
||||
|
||||
无参的 `@injectable()` 等于 `Singleton`。新代码要写出 `'Singleton'` 或 `'Transient'`。
|
||||
|
||||
## 端口怎么绑到实现
|
||||
|
||||
用例依赖的是抽象类 `ISecurityRepository`,容器默认不知道用哪个实现。实现类同时标上:
|
||||
|
||||
```ts
|
||||
@bindAs(ISecurityRepository)
|
||||
@injectable('Singleton')
|
||||
export class SecurityRepository implements ISecurityRepository {}
|
||||
```
|
||||
|
||||
`@bindAs` 写在 `@injectable` 上面。类装饰器从靠近类的那一个开始执行,`@injectable` 先记下构造函数参数的类型,`@bindAs` 再把端口绑到这个实现。这个类文件必须被执行到,绑定才会发生。执行方式见 [代码实践](./代码实践.md) 里的模块入口。
|
||||
|
||||
只在 `infra` 的端口实现上使用 `@bindAs`。用例和 ViewModel 按类本身注册。
|
||||
|
||||
## 同一个端口的多个实现
|
||||
|
||||
`IProjectRepository` 有本地和远程两个实现。绑定时带上名称,容器里才会同时留下两份:
|
||||
|
||||
```ts
|
||||
@bindAs(IProjectRepository, ProjectStore.LOCAL)
|
||||
@injectable('Singleton')
|
||||
export class LocalProjectRepository implements IProjectRepository {}
|
||||
```
|
||||
|
||||
`RemoteProjectRepository` 使用 `ProjectStore.REMOTE`。两个实现留在同一个端口上,用名称区分。
|
||||
|
||||
用例的构造函数依赖抽象端口。两个参数类型都是 `IProjectRepository`,类型元数据区分不了本地和远程,所以用 `@inject` 指定端口,用 `@named` 指定实现:
|
||||
|
||||
```ts
|
||||
constructor(
|
||||
@inject(IProjectRepository) @named(ProjectStore.LOCAL) private localRepository: IProjectRepository,
|
||||
@inject(IProjectRepository) @named(ProjectStore.REMOTE) private remoteRepository: IProjectRepository,
|
||||
) {}
|
||||
```
|
||||
|
||||
页面只传入 `ProjectStore`,不自己决定用 IndexedDB 还是 `Http`。
|
||||
|
||||
## 装饰器能生效的条件
|
||||
|
||||
JavaScript 运行时不保留构造函数参数的类型。`tsconfig.app.json` 打开 `experimentalDecorators` 和 `emitDecoratorMetadata` 后,编译器把参数类型写成 `design:paramtypes`。容器读这个数组,按类型创建依赖。
|
||||
|
||||
`SecurityApplication` 的参数类型分别是 `ISecurityRepository` 和 `TokenStore`,数组里就能分开,构造函数只声明类型。`ProjectApplication` 的两个参数类型都是 `IProjectRepository`。上一节用 `@inject` 指定端口,用 `@named(ProjectStore.LOCAL)` 和 `@named(ProjectStore.REMOTE)` 指定实现,容器按名称选择。
|
||||
|
||||
跨目录引用写 `@/app/...` 或 `@/features/...`。只表示类型的导入写成 `import type`,避免把类型当成运行时代码。
|
||||
53
doc/基础知识/mobx是什么.md
Normal file
53
doc/基础知识/mobx是什么.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# mobx是什么
|
||||
|
||||
MobX 负责页面上会变化的状态。mobx-keystone 负责可以保存、比较和恢复的实体。页面组件只负责渲染,不存放业务状态。
|
||||
|
||||
## 目录
|
||||
|
||||
- [页面状态](#页面状态)
|
||||
- [ViewModel 的生命周期](#viewmodel-的生命周期)
|
||||
- [实体与快照](#实体与快照)
|
||||
|
||||
## 页面状态
|
||||
|
||||
ViewModel 里需要触发重渲染的字段标成 `observable`,修改这些字段的方法标成 `action`。组件用 `mobx-react-lite` 的 `observer` 包起来,字段变化后才会重画。
|
||||
|
||||
`SecurityViewModel` 的 `errorMessage`、`isLoading` 就是页面状态。它们跟着这一次打开的登录页,所以这个类标 `@injectable('Transient')`。
|
||||
|
||||
普通字段不用 observable。不会显示在界面上的局部变量也不放进 ViewModel。
|
||||
|
||||
## ViewModel 的生命周期
|
||||
|
||||
ViewModel 继承 `PageViewModel`。页面不自己 `new`,而调用 `useViewModel`。
|
||||
|
||||
```ts
|
||||
export function useViewModel<T extends PageViewModel>(identifier: new (...args: any[]) => T): T {
|
||||
const [viewModel] = useState(() => {
|
||||
const vm = rootContainer.get(identifier);
|
||||
vm.created();
|
||||
return vm;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
viewModel.ready();
|
||||
const onPageHide = () => viewModel.beforeRefresh();
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
viewModel.dispose();
|
||||
};
|
||||
}, [viewModel]);
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
```
|
||||
|
||||
`created` 在实例取出时调用。`ready` 在页面挂上后调用,适合开始加载数据,例如 `HomeViewModel.ready` 去拉项目列表。`beforeRefresh` 在页面被浏览器收起时调用。离开页面时调用 `dispose`。
|
||||
|
||||
## 实体与快照
|
||||
|
||||
`Project`、`Document`、`LineShape` 使用 mobx-keystone 的 `@model`。要保存的字段用 `tProp` 声明,修改实体的方法标 `@modelAction`。对外改文档的方法在聚合根上:`Project.addShape`、`Project.undo`、`Project.redo`、`Project.touch`。
|
||||
|
||||
快照是实体在某一刻的普通对象。`getSnapshot` 取出它,`applySnapshot` 把实体恢复成那一份。`Project.addShape` 接受图形前,把 `Document` 的快照放进聚合自己的 `past`,`undo` 和 `redo` 再用 `applySnapshot` 换回来。`past` 和 `future` 在 `onInit` 里标成 `observable`,页面才能根据 `canUndo`、`canRedo` 重绘。它们不是 `tProp`,`getSnapshot(project)` 不包含撤销栈。
|
||||
|
||||
保存到本地库或提交给接口之前,仓储取出项目快照再写入。实体类仍然留在内存里给界面使用。编辑器 ViewModel 只持有当前的 `project`、`store`、工具和草稿。
|
||||
50
doc/基础知识/代码实践.md
Normal file
50
doc/基础知识/代码实践.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 代码实践
|
||||
|
||||
沿登录走一遍。目的是看见页面、入口文件、ViewModel、用例、端口和网络怎样接在一起。分层见 [DDD是什么](./DDD是什么.md),容器见 [DI是什么](./DI是什么.md)。
|
||||
|
||||
## 目录
|
||||
|
||||
- [入口文件先执行](#入口文件先执行)
|
||||
- [页面取出 ViewModel](#页面取出-viewmodel)
|
||||
- [用例只依赖端口](#用例只依赖端口)
|
||||
- [实现负责请求](#实现负责请求)
|
||||
- [少了入口 import 会怎样](#少了入口-import-会怎样)
|
||||
|
||||
## 入口文件先执行
|
||||
|
||||
`src/features/security/security.ts` 没有导出,只有两行 import:
|
||||
|
||||
```ts
|
||||
import './i18n';
|
||||
import './infra/persistence/security_repository';
|
||||
```
|
||||
|
||||
第一行执行 `registerMessages`,登录页的错误文案才能按键取到。第二行执行 `SecurityRepository` 上的 `@bindAs(ISecurityRepository)`,容器才知道端口用哪个类。
|
||||
|
||||
登录页第一行是 `import '../../security'`。这行不使用任何导出,作用是让上面两个文件运行。
|
||||
|
||||
## 页面取出 ViewModel
|
||||
|
||||
`security.tsx` 是默认导出的页面,由 `src/app/routes.ts` 挂在 `/security`,并放在 `guest` 布局里。已登录用户进不了这个路径。
|
||||
|
||||
页面是 `observer`,用 `useViewModel(SecurityViewModel)` 取出 ViewModel。用户提交时调用 `submit`。ViewModel 是 `Transient`,这次填写的账号和错误只留在这一次打开的页面里。
|
||||
|
||||
## 用例只依赖端口
|
||||
|
||||
`submit` 在登录页校验 `AuthCredentials`。通过后调用:
|
||||
|
||||
```ts
|
||||
await this.application.login(this.loginForm);
|
||||
```
|
||||
|
||||
`SecurityApplication.login` 不写 axios。它调用 `ISecurityRepository.login`,拿到 `LoginResult` 后执行 `tokenStore.save(result.accessToken)`。凭证怎样通知界面,由 `src/app/session` 负责。
|
||||
|
||||
## 实现负责请求
|
||||
|
||||
`SecurityRepository.login` 把实体收成快照,再调用 `this.http.instance.post('/auth/login', plainDto)`。响应收成 `User` 和 `LoginResult` 后返回给用例。`Http` 会在请求上附带已有凭证,并处理 401。这些步骤不写进用例。
|
||||
|
||||
## 少了入口 import 会怎样
|
||||
|
||||
页面如果没有 `import '../../security'`,`security.ts` 不会执行。文案没有注册,`ISecurityRepository` 也没有绑定。`useViewModel` 创建 `SecurityViewModel` 时,容器无法提供 `SecurityApplication` 所依赖的仓储。
|
||||
|
||||
项目模块同样依赖 `src/features/project/project.ts`。它导入本地和远程两个仓储,两者分别用 `@bindAs(IProjectRepository, ProjectStore.LOCAL)` 和 `ProjectStore.REMOTE` 绑到同一个端口。用例用 `@inject` 和 `@named` 注入对应实现。首页和编辑器在文件顶部写 `import '@/features/project/project'`,本地列表和远程列表写 `import '../../project'`。新增仓储时,把实现文件加进对应的入口,并保证被路由加载到的页面引用了这个入口。
|
||||
33
doc/基础知识/常见设计模式及使用.md
Normal file
33
doc/基础知识/常见设计模式及使用.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 常见设计模式及使用
|
||||
|
||||
本篇只写这套脚手架里会碰到的三种写法。名称用来对照代码,不展开其他模式。
|
||||
|
||||
## 目录
|
||||
|
||||
- [依赖注入](#依赖注入)
|
||||
- [仓储](#仓储)
|
||||
- [观察者](#观察者)
|
||||
|
||||
## 依赖注入
|
||||
|
||||
调用方不创建依赖,容器按构造函数传入。`SecurityApplication` 需要仓储和 `TokenStore` 时,只声明参数类型,不写 `new SecurityRepository()`。
|
||||
|
||||
依赖倒置、注入方式和 `Singleton`、`Transient`、`@bindAs` 的用法见 [DI是什么](./DI是什么.md)。
|
||||
|
||||
## 仓储
|
||||
|
||||
仓储把「业务要的数据」和「数据放在哪」分开。
|
||||
|
||||
`ISecurityRepository` 声明 `login`,返回 `LoginResult`。用例只调用这个方法,再把 `accessToken` 写入 `TokenStore`。`SecurityRepository` 才知道要访问 `/auth/login`,并把响应收成 `User`。
|
||||
|
||||
项目模块只有一个端口 `IProjectRepository`,声明 `list`、`get`、`save`。本地实现走 IndexedDB,远程实现走 `Http`,都实现这个端口。`ProjectApplication` 按 `store` 选择其中一个。
|
||||
|
||||
因此 `domain` 不出现 axios 和 IndexedDB,`infra` 不决定登录成功后要做什么。
|
||||
|
||||
## 观察者
|
||||
|
||||
状态变化后,订阅者自己更新。本仓库有两处。
|
||||
|
||||
MobX 的 `observable` 字段变化时,包了 `observer` 的组件重绘。这是页面状态到界面的观察者。
|
||||
|
||||
`TokenStore.save` 和 `TokenStore.remove` 调用 `notifyAuthChanged`。`useAuthenticated` 通过 `subscribeAuth` 收到 `AUTH_CHANGED` 后再读一次 `hasSession`。登录态变化时,首页不必自己轮询。
|
||||
64
doc/基础知识/当前框架技术栈说明.md
Normal file
64
doc/基础知识/当前框架技术栈说明.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 当前框架技术栈说明
|
||||
|
||||
本目录讲上手这套脚手架需要的概念。本仓库怎么分层、模块之间数据怎么走,见 [项目架构](../项目文档/项目架构.md)。新代码怎么命名、怎么接进容器,见 [代码撰写规范](../项目文档/代码撰写规范.md)。
|
||||
|
||||
## 目录
|
||||
|
||||
- [阅读顺序](#阅读顺序)
|
||||
- [技术栈](#技术栈)
|
||||
- [样式](#样式)
|
||||
- [路由怎么挂页面](#路由怎么挂页面)
|
||||
|
||||
## 阅读顺序
|
||||
|
||||
1. 本文。
|
||||
2. [DDD是什么](./DDD是什么.md)。知道四层、聚合根和端口各自放什么。
|
||||
3. [DI是什么](./DI是什么.md)。知道类怎样进容器,页面怎样拿到它们。
|
||||
4. [mobx是什么](./mobx是什么.md)。知道页面状态、实体和快照。
|
||||
5. [常见设计模式及使用](./常见设计模式及使用.md)。只看本仓库用到的几种。
|
||||
6. [代码实践](./代码实践.md)。沿登录走一遍。
|
||||
|
||||
国际化、数据库、Worker 的用法在 `doc/项目文档/基础应用/`。界面组件使用 Ant Design,不在本目录展开。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 作用 | 库 |
|
||||
|---|---|
|
||||
| 界面 | React |
|
||||
| 路由 | React Router。路由表在 `src/app/routes.ts` |
|
||||
| 构建 | Vite。环境文件在 `src/`,只暴露 `VITE_` 开头的变量 |
|
||||
| 语言 | TypeScript。装饰器需要 `experimentalDecorators` 和 `emitDecoratorMetadata` |
|
||||
| 依赖注入 | Inversify。容器在 `src/app/di/root_container.ts` |
|
||||
| 页面状态 | MobX,配合 `mobx-react-lite` 的 `observer` |
|
||||
| 实体 | mobx-keystone 的 `@model` |
|
||||
| 请求 | axios。封装在 `src/app/network` |
|
||||
| 文案 | i18next |
|
||||
| 样式 | Sass |
|
||||
|
||||
路径别名 `@/` 指向 `src/`。跨目录用它,同一功能模块内部用相对路径。
|
||||
|
||||
## 样式
|
||||
|
||||
组件样式用同名 `.scss`,在组件文件里 import。主题色由 `src/app/theme` 解析后交给 Ant Design,不在每个组件里各写一套亮色和暗色。
|
||||
|
||||
## 路由怎么挂页面
|
||||
|
||||
页面不自己注册路由。`src/app/routes.ts` 把 URL 指到 `presentation` 下的页面文件,页面使用默认导出。
|
||||
|
||||
```ts
|
||||
export default [
|
||||
layout('routes/app_shell.tsx', [
|
||||
index('../features/home/presentation/home/home.tsx'),
|
||||
layout('routes/layouts/guest.layout.tsx', [
|
||||
route('security', '../features/security/presentation/security/security.tsx'),
|
||||
]),
|
||||
layout('routes/layouts/auth.layout.tsx', [
|
||||
route('editor/:projectId', '../features/editor/presentation/editor/editor.tsx'),
|
||||
]),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
`app_shell` 提供文案和主题。`guest` 只允许未登录进入,例如 `/security`。`auth` 只允许已登录进入,例如 `/editor/:projectId`。判断使用 `hasSession()`,规则在 `decideRoutePolicy`。
|
||||
|
||||
新增页面时:在功能模块的 `presentation` 写下默认导出的页面,再在这张表里登记路径和布局。
|
||||
Reference in New Issue
Block a user