こんにちは。
Web・iOSエンジニアの三浦です。
今回は、最近開発している Vue.js(Typescript)について、実際に使っているディレクトリ構造を紹介します。
はじめに
初めて Vue.js で開発をしたときは、基本に則った Atomic Design を使用していました。
しかしコンポーネントが増加するにつれ、どのように分けるべきか、同レイヤー間の呼び出しを許可するかなど煩雑さが増していきました。
その後別プロジェクトを Vue.js + Typescript で開発する機会があり、前回の反省を踏まえて少し Atomic Desigin をカスタマイズしてみることにしました。
ディレクトリ構造
現状以下のディレクトリ構造を使っています。
基本的な考え方は Atomic Design ですが、こちらの「Atomic DesignをVue.jsで実現するための構成と考え方 | Biscuetでの例をもとに」をかなり参考にさせていただいています。
なおこれ以外は、 Vue CLI のデフォルトの構造から変えていません。
src
├── components // ページ要素コンポーネント
│ ├── organisms // 各ページに固有の要素
│ │ └── ...
│ └── pages // 各ページ
│ └── ...
├── plugins // グローバルインストールするプラグイン
│ └── materials // 汎用要素
│ ├── atoms // 最小単位の汎用要素
│ │ └── ...
│ ├── molecules // 最小単位の要素を組み合わせた汎用要素
│ │ └── ...
│ ├── index.ts // materialsをグローバルインストール
│ └── ...
├── constants // 定数
│ └── ...
├── managers // マネージャ
│ └── ...
├── models // データモデル
│ └── ...
├── usecases // 各ページのユースケース
│ └── ...
├── utils // ヘルパー
│ └── ...
│
│ // 以下はデフォルトで存在する
│
├── assets // 画像・CSS等アセット
│ └── ...
├── router // ルーター
│ └── ...
├── store // vuexストア
│ └── ...
├── App.vue // 画面フレーム
└── main.ts // 各ライブラリ等読み込み
以下では、Vue CLIには存在しないこのディレクトリ構造独自の部分を説明していきます。
components
Atomic Designにおける pages
と organisms
を格納するディレクトリです。
「Atomic DesignをVue.jsで実現するための構成と考え方 | Biscuetでの例をもとに」に倣っており、そちらに説明されている通り冗長になりがちな templates
は排除しています。
plugins/materials
Atomic Designにおける molecules
と atoms
を格納しています。
こちらも「Atomic DesignをVue.jsで実現するための構成と考え方 | Biscuetでの例をもとに」に倣っており、 plugins/materials/index.ts
を使ってグローバルインストールさせています。
グローバルインストールさせていることからも分かる通り、ここに格納されるコンポーネントはいわば Vuetify などのようなライブラリ的使い方を想定しており、そのためどこでも使い回せるようなコードにすることが意識付けられます。
上記の Atomic Design の分け方にしたことで、「どのようにコンポーネントを分け、どのレイヤーに入れるべきか」、また「同レイヤー間の呼び出しをどうするか」はかなりスッキリ考えられるようになったと思います。
なお、「Atomic DesignをVue.jsで実現するための構成と考え方 | Biscuetでの例をもとに」で紹介されていた plugins/materials/index.js
は、Typescriptとして書き直すと以下のようになります。
import { VueConstructor } from "vue/types/umd";
// @/plugins/materials 配下の全vueファイルを取得
const context = require.context(".", true, /.vue$/);
const components: { [key: string]: any } = {};
// 取得したコンポーネント一覧を、ファイル名がキーのオブジェクトに変換
context.keys().forEach(contextKey => {
const keys = contextKey.match(/.+\/(.+)\.vue/);
if (keys) {
const key = keys[1];
components[key] = context(contextKey).default;
}
});
// ファイル名をキーとしたコンポーネントとしてグローバル登録
export default {
install(Vue: VueConstructor) {
Object.keys(components).forEach(key => {
Vue.component(key, components[key]);
});
}
};
ここからは、各コンポーネントで使用するデータや処理を、どのようにコンポーネント外で定義するかになります。
constants
定数を定義します。
色々なところで使い回すようなものや、データサイズが大きいものなどはここに入れるようにしています。
例:画面幅の定数
const DISPLAY: {
sm: {
start: number;
};
md: {
start: number;
};
lg: {
start: number;
};
xl: {
start: number;
};
} = {
sm: {
start: 600
},
md: {
start: 960
},
lg: {
start: 1264
},
xl: {
start: 1904
}
};
export default DISPLAY;
managers
例えばAPIへの接続処理など、ある程度複雑で、かつ色々なところで使い回すような処理はここに入れています。
例:データを受け取ってAPIへ投げ、返り値を指定のクラスに変換して返すマネージャ
import axios, { AxiosResponse, AxiosRequestConfig } from "axios";
import { Request, RequestMethodType } from "@/models/request/baseRequest";
import Decoder from "@/models/request/baseDecoder";
const APIManager: {
request: <P, B, R>(request: Request<P>, decoder: Decoder<B, R>) => Promise<R>;
} = {
/**
* APIリクエスト
*
* @template P APIリクエスト時に投げるパラメータ
* @template B APIレスポンスの型
* @template R レスポンスを変換する型
* @param {Request<P>} request リクエスト型
* @param {Decoder<B, R>} decoder デコーダ
* @returns {Promise<R>} レスポンスを型変換した値
*/
request: async <P, B, R>(
request: Request<P>,
decoder: Decoder<B, R>
): Promise<R> => {
let method: AxiosRequestConfig["method"];
switch (request.method) {
case RequestMethodType.post:
method = "post";
break;
case RequestMethodType.get:
default:
method = "get";
break;
}
let response: AxiosResponse<any> = await axios({
method: method,
baseURL: request.baseURL,
url: request.path,
data: request.parameters
});
if (typeof response.data === "undefined") {
throw new Error("data could not be gotten.");
}
return decoder.decode(JSON.parse(response.data));
}
};
export default APIManager;
models
APIのレスポンスの型など、一つのデータとして固めておきたい塊を入れるモデルクラスを定義します。
例:APIへのリクエストモデル
import { Request, RequestMethodType } from "@/models/request/baseRequest";
export type TextParameterType = { text: string };
export class TextRequest implements Request<TextParameterType> {
private _method: RequestMethodType = RequestMethodType.post;
private _baseURL: string = "http://localhost:8080";
private _path: string = "/text";
private _parameters: TextParameterType;
constructor(text: string) {
this._parameters = { text: text };
}
get method(): RequestMethodType {
return this._method;
}
get baseURL(): string {
return this._baseURL;
}
get path(): string {
return this._path;
}
get parameters(): TextParameterType {
return this._parameters;
}
}
usecases
一定以上の複雑なページを作る時、 pages
や organisms
にすべての処理を書くと、ファイルが長くなりすぎて可読性に欠ける場合があります。
そのため、コンポーネント側にはviewとの接続を主に担当させ、具体的なロジックをユースケース側に分離して書くようにすることで可読性を向上させることができます。
例
コンポーネント側
@Watch("text")
onChangeText() {
this.submitEnabled = TextUseCase.submitEnabled(
this.textString,
this.maxLength
);
}
ユースケース側
submitEnabled: (text: string, maxLength: number): boolean =>
0 < text.length && text.length < maxLength
utils
全体的に使う、ちょっとしたヘルパー関数を入れています。
例:スリープ処理
const sleep: (msec: number) => Promise<any> = msec =>
new Promise(resolve => setTimeout(resolve, msec));
export default sleep;
おわりに
いかがだったでしょうか。
もちろんこれが必ずしも正解とは言えないですし、これ自体に関してもまだ完成ではなく、例えば managers
と utils
の境はどの程度の複雑性になるのか、であるとか、DIをしたい場合はさらに別途ディレクトリ構成が必要になったりはあると思いますが、現状のプロジェクトの規模だとこれで良い感じに回っている印象です。
参考になれば幸いです!
参考文献
以下のサイトを参考にさせていただきました。
ありがとうございました!