- フォルダ構成
- View の表示
- ユースケースのコードによる表現
- ドメインモデルの実装
- プレゼンテーション層でのユースケース呼び出し
- インフラ層と依存性逆転の原則
- 振る舞い駆動開発
TypeScript + pug + SASS
$ yarn create vite CABoilerplate --template vue-ts
$ yarn add --dev pug sass
TypeScript 側と Vite 側の両方でパスのエイリアスを指定する必要があります。
$ yarn add --dev @types/node
{
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
, "@views/*": ["src/service/presentation/views/*"]
},
...
},
}
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import * as path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
, resolve: {
alias: {
"@": path.resolve(__dirname, "src/")
, "@views": path.resolve(__dirname, "src/service/presentation/views")
}
}
})
以下のようにし、自動生成される env.d.ts
は system/types 以下へ移動させます。
src/
├── service
│ ├── application
│ ├── domain
│ │ ├── interfaces
│ │ └── models
│ ├── infrastructure
│ └── presentation
│ └── views
├── system
│ └── types
│ └── env.d.ts
├── App.vue
└── main.ts
src/service/presentation/views 以下に Home.vue と Signin.vue を用意します。
<script setup lang="ts">
</script>
<template lang="pug">
h1 Home
router-link(to="/signin") -> Signin
</template>
<script setup lang="ts">
</script>
<template lang="pug">
h1 Signin
</template>
Vue Router 4.x を利用します。
$ yarn add vue-router@4 rxjs
<script setup lang="ts">
</script>
<template lang="pug">
router-view
</template>
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import Home from '@views/Home.vue'
import Signin from '@views/Signin.vue'
const routes = [
{ path: '/', component: Home }
, { path: '/signin', component: Signin }
]
const router = createRouter({
history: createWebHashHistory()
, routes
})
const app = createApp(App)
app.use(router)
app.mount('#app')
ここではユースケースを const enum と Union 型で表現します。
service/application/useases フォルダを作成し、boot.ts ファイルを新規作成します。
ユースケースシナリオを以下のように const enum で表現します。 また、シナリオの各シーンを Union 型で定義します。
/**
* usecase: アプリを起動する
*/
export const enum Boot {
/* 基本コース */
userOpenSite = "ユーザはサイトを開く"
, serviceCheckSession = "サービスはセッションがあるかを確認する"
, sessionExistsThenPresentHome = "セッションがある場合_サービスはホーム画面を表示する"
/* 代替コース */
, sessionNotExistsThenPreesntSignin = "セッションがない場合_サービスはログイン画面を表示する"
}
// 代数的データ型 @see: https://qiita.com/xmeta/items/91dfb24fa87c3a9f5993#typescript-1
export type BootContext = { scene: Boot.userOpenSite }
| { scene: Boot.serviceCheckSession }
| { scene: Boot.sessionExistsThenPresentHome }
| { scene: Boot.sessionNotExistsThenPreesntSignin }
;
const enum で定義したユースケースのシナリオを実行可能にします。 具体的にはシナリオの一つひとつのシーンを Scene オブジェクトとして定義し、これを再起呼び出しを使って処理していくようにします。
system/interfaces フォルダを作成し、usecase.ts ファイルを新規作成します。
import { Observable, of } from "rxjs";
import { mergeMap, map } from "rxjs/operators";
interface Scene<T> {
context: T;
next: () => Observable<this> | null;
}
export abstract class AbstractScene<T> implements Scene<T> {
abstract context: T;
abstract next(): Observable<this> | null;
protected instantiate(nextContext: T): this {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new (this.constructor as any)(nextContext);
}
just(nextContext: T): Observable<this> {
return of(this.instantiate(nextContext));
}
}
export class Usecase {
static interact<T, U extends Scene<T>>(initialScene: U): Observable<T[]> {
const _interact = (senario: U[]): Observable<U[]> => {
const lastScene = senario.slice(-1)[0];
const observable = lastScene.next();
// 再帰の終了条件
if (!observable) {
// console.log(`[usecase:${lastScene.constructor.name.replace("Scene", "")}:${senario.length-1}:END ]`, lastScene.context );
return of(senario);
} else {
const tag = (senario.length === 1) ? "START " : "PROCESS";
// console.log(`[usecase:${lastScene.constructor.name.replace("Scene", "")}:${senario.length-1}:${tag}]`, lastScene.context );
}
// 再帰処理
return observable
.pipe(
mergeMap((nextScene: U) => {
senario.push(nextScene);
return _interact(senario);
})
);
};
return _interact([initialScene])
.pipe(
map((scenes: U[]) => {
const performedSenario = scenes.map(scene => scene.context);
console.log("performedSenario:", performedSenario);
return performedSenario;
})
);
}
}
BootScene を Scene インタフェースに準拠するようにし、next 関数を実装します。 next 関数は、自身が表すシーンの次のシーンを指定します。処理終了の場合には null を返すようにします。
export class BootScene extends AbstractScene<BootContext> {
context: BootContext;
constructor(context: BootContext = { scene: Boot.userOpenSite }) {
super();
this.context = context;
}
next(): Observable<this>|null {
switch (this.context.scene) {
case Boot.userOpenSite: {
// TODO
}
case Boot.serviceCheckSession : {
// TODO
}
case Boot.sessionExistsThenPresentHome: {
// TODO
}
case Boot.sessionNotExistsThenPreesntSignin: {
// TODO
}
}
}
}
例えば以下のようにし、check 関数の中でサインインセッションがあるか否かを調べることとします。
next(): Observable<this>|null {
switch (this.context.scene) {
case Boot.userOpenSite: {
return this.just({ scene: Boot.serviceCheckSession });
}
case Boot.serviceCheckSession : {
return this.check();
}
case Boot.sessionExistsThenPresentHome: {
return null;
}
case Boot.sessionNotExistsThenPreesntSignin: {
return null;
}
}
}
private check(): Observable<this> {
if (/* TODO: ドメインモデルが持つメソッドが結果を返すようにする */ false) {
return of(this.instantiate({ scene: Boot.sessionExistsThenPresentHome }));
} else {
return of(this.instantiate({ scene: Boot.sessionNotExistsThenPreesntSignin }));
}
}
ユーザの入力イベントなどをトリガーとして、プレゼンテーション層からユースケースを実行する必要があります。
以下のように、Boot ユースケースを初期化し、interact 関数を実行し、結果をサブスクライブするようにします(これをどこに実装するかについては 5.2 参照)。 結果は実際に実行された Scene の配列(これを scenario と呼ぶことにします)で返ってくるので、その最後の Scene が何だったかによって、次の処理を変更します。
import { Usecase } from "@/system/interfaces/usecase";
import { Boot, BootScene } from "@usecases/boot";
import type { BootContext } from "@usecases/boot";
import { Subscription } from "rxjs";
const boot = () => {
let subscription: Subscription | null = null;
subscription = Usecase.interact<BootContext, BootScene>(
new BootScene()
).subscribe({
next: (performedSenario) => {
const lastContext = performedSenario.slice(-1)[0];
switch (lastContext.scene) {
case Boot.sessionExistsThenPresentHome:
// TODO
break;
case Boot.sessionNotExistsThenPreesntSignin:
// TODO
break;
}
},
error: (e) => console.error(e),
complete: () => {
console.info("complete");
subscription?.unsubscribe();
},
});
};
$ yarn add firebase
vue-cli を入れる
$ yarn global add @vue/cli
$ vue add vuetify
? Choose a preset: (Use arrow keys)
Configure (advanced)
Default (recommended)
❯ Vite Preview (Vuetify 3 + Vite)
Prototype (rapid development)
Vuetify 3 Preview (Vuetify 3)
src 以下に plugins フォルダができるので、system 以下に移動する。 vite.config.js ファイルが自動生成されるので、(.ts と重複するため)削除し、.ts を以下のように書き換える。
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from '@vuetify/vite-plugin'
import * as path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue()
// https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin
, vuetify({
autoImport: true,
})
]
, define: { 'process.env': {} }
...
})
import { createApp } from 'vue'
import vuetify from '@/system/plugins/vuetify'
import { loadFonts } from '@/system/plugins/webfontloader'
...
loadFonts()
const app = createApp(App);
app.use(vuetify);
app.use(router);
app.mount('#app');
<script setup lang="ts">
</script>
<template lang="pug">
v-app
v-main
router-view
</template>
ゴール:TypeScript と vue ファイル内の Pug に対し、保存時に自動でフォーマットがなされるようにする。 実現方法:
- .ts/.vue の TypeScript は ESLint に、
- .vue の pug は Vetur 経由で Prettier に、
- .json は Prettier に
任せる(ESLint で pug 向けの plugin がないため)。
$ yarn add --dev eslint
$ yarn create @eslint/config
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · vue
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · browser
✔ How would you like to define a style for your project? · prompt
✔ What format do you want your config file to be in? · JavaScript
✔ What style of indentation do you use? · 4
✔ What quotes do you use for strings? · double
✔ What line endings do you use? · unix
✔ Do you require semicolons? · No / Yes
module.exports = {
env: {
browser: true
, es2021: true
, node: true
}
, extends: [
"eslint:recommended"
, "plugin:vue/vue3-recommended"
, "plugin:@typescript-eslint/recommended"
]
, parser: "vue-eslint-parser"
, parserOptions: {
ecmaVersion: "latest"
, parser: "@typescript-eslint/parser"
, sourceType: "module"
}
, plugins: ["vue", "@typescript-eslint"]
, rules: {
indent: ["error", 4]
, quotes: ["warn", "double"]
, semi: ["warn", "always"]
, "comma-style": ["warn", "first"]
, "comma-spacing": ["warn", { before: false, after: true }]
, "comma-dangle": ["warn", "never"]
, "no-var": ["error"]
, "no-console": ["off"]
, "no-unused-vars": ["off"]
, "no-mixed-spaces-and-tabs": ["warn"]
, "no-warning-comments": ["warn", { terms: ["todo"], location: "anywhere" }]
}
};
"dbaeumer.vscode-eslint" をインストール。
{
"eslint.packageManager": "yarn",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.format.enable": true,
"eslint.validate": [
"typescript",
"javascript",
"javascriptreact",
"vue"
]
}
$ yarn add --dev prettier @prettier/plugin-pug
"octref.vetur", "esbenp.prettier-vscode" をインストール。
"vetur.format.defaultFormatter.ts": "none" として、prettier を抑制し、ESLint のみが利くようにする。
{
...
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[vue]": {
"editor.defaultFormatter": "octref.vetur",
},
"vetur.format.enable": true,
"vetur.format.defaultFormatter.ts": "none",
"vetur.format.defaultFormatter.pug": "prettier",
"editor.formatOnSave": true,
}