はじめに

普段、WEB開発をメインに頑張ってるシステム開発部のCです。最近、Reactを使うことがあって、個人的にもReact+TSを使った開発を好んでます。個人的な見解に過ぎない部分もあるかもしれませんが、Reactで作成してゆくと誰が書いても大体似た書き方になり、メンテナビリティも上がって良いですよね!

とあるとき、vue2のプロジェクトに初めて触れられる機会がありました。とても興味をもって取り組んだのですが、はじめて触れるというのもあったかもしれませんが、なんだかコードが追いづらい作りになってるなぁって思ってしまったのを覚えています。

きっと作る人によって違いがあるだけで、きっとなにかベストプラクティスがあって自分がただ知らないだけなんだろうなとは思ってはいました。
たしかにネットで調べるととても参考になるvue2のベストプラクティスも多く見つかりましたが、それと同時に大規模開発には向いてない、書き方が散らばって見ずらい、TSのサポートが弱いなどの厳しめの評価の記事が国内、海外とわずよく目にすることがありました。

そんなとき、vue3では色々と改善や変更がはいり、TSも正式サポートされたとかという朗報を耳にしてとても試したくなった興味が持てました!

次回、vue3を使った開発があったときのための軽い知見のために簡単なアプリを作ってみました。

vue 3 の新しい特徴

今回は、Composition APIを試してみました。

Composition API※ コンポーネントの従来(Option API)よりも書きやすくしてくれるAPI。今回のコンポーネントはこちらをベースにしました。
Multiple root elements 従来直下で記述できたタグは1つだけだったので、全体を単一のタグで囲むのが一般的でしたが、今回のこの機能で複数タグの記述に対応
Suspense 非同期処理が解決されるまでフォールバックコンテンツを表示してくれる特別なコンポーネント。以前までは状態変数(v-if="loading===true")の状態変数などで制御していたものを、状態変数使わず関節に書くことができます。
Multiple V-models 従来双方向データバインディングを実現するv-modelディレクティブは、コンポーネントにつき1つのみ定義できたのが、この機能により複製定義可能に。
Better Reactivity 状態の変化を作成、監視、対応するためのスタンドアロンなAPIで、公式のgithubでは、statevaluecomputedwatchを使った例が紹介されてます。
teleport 定義したコンポーネントが属するDOMのツリーとは違う場所にコンポーネントを移動できる機能です。

環境

PKG version 説明
vue 3.0.0-0 vueの本体
vuex 4.0.0-0 アプリ内のデータを一元管理するためのフレームワーク
tailwindcss 1.8.10 cssのフレームワーク
typescript 3.9.3 typescriptの本体

環境構築

vue3のプロジェクトを構築

※ 以下で新規にプロジェクトを作ります。

# vueの雛形を作るためのコマンドツール
npm i -g @vue/cli

# 雛形の作成
vue create `プロジェクトフォルダ名`

※ 雛形のコマンド叩いた後、設定等は以下のように進めました。その他、ここに載せてないものはデフォルトや好みで選びました。


? Please pick a preset:
Default ([Vue 2] babel, eslint)
Default (Vue 3 Preview) ([Vue 3] babel, eslint)
❯ Manually select features //★手動を選択


Vue CLI v4.5.6
? Please pick a preset: Manually select features
? Check the features needed for your project:
◉ Choose Vue version //◉は選択したものです。
◉ Babel
◉ TypeScript
◯ Progressive Web App (PWA) Support
◉ Router
❯◉ Vuex
◉ CSS Pre-processors
◉ Linter / Formatter
◉ Unit Testing
◉ E2E Testing


Vue CLI v4.5.6
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, Vuex, CSS Pre-processors, Linter, Unit, E2E
? Choose a version of Vue.js that you want to start the project with
2.x
❯ 3.x (Preview) ★選択

④ 以降は `enter` or `y` で進めました(今回のアプリでは重要ではないので)
Vue CLI v4.5.6
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, Vuex, CSS Pre-processors, Linter, Unit, E2E
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? (y/N) y? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? (Y/n) ? Use class-style component syntax? (y/N) y

アプリを起動

cd `プロジェクトフォルダ名`
npm run build
npm run serve

新規作成後のフォルダー構成

$ tree src/
src/
├── App.vue
├── assets
├── components //コンポーネントファイルを格納
│   └── HelloWorld.vue
├── main.ts //エントリーポイント。globalで設定しておきたい処理などを行える
├── router //アプリのルーティングを定義する場所
│   └── index.ts
├── shims-vue.d.ts
├── store //ストアを定義する場所
│   └── index.ts
└── views //各ページのトップの親のコンポーネントを置く。
├── About.vue
└── Home.vue

5 directories, 9 files

vue3の為に、その他インストールしたパッケージです。

# 開発時で、コンポーネント内からvuexのgetters,state,actionsを用いようとしたときにインテリジェンス(入力支援)が聞いてほしいため
npm i -D vuex-module-decorators

今回は記述量削減の為、tailwind cssを採用しました。

tailwind cssとは

utility classを活用したcssフレームワークです。bootstrapやbulmaのような事前に用意されたUI部品を活用してゆくようなものではなく、utility class を使って独自のボタンを作成していけるまた違ったアプローチが楽しめるフレームワークです。作られた目的としては、記述量削減、可読性の向上などがあります。

pタグ一つに対してのみピンポイントで独自のスタイルもutility classで定義出来ます。そのような記述じゃ逆にclass="XXX"周りが長ったらしくなって見づらくはないかとも思われますが、外部化が可能で、その部分のスタイルを表す名前(エリアス)として利用もできます。外部化ができるとそのスタイルを外でも使いませますね!

以下の例は、tailwindをHTMLで使用したとき(上部)とCSSを使用したとき(下部)を比較した例があります。実現したいものは一緒ですが、前者のほうが可読性がよく、記述量も削減されている印象を受けるのでないでしょうか。

cssのフレームのセットアップ

以下の手順でセットアップします。

※ tailwindcssとVendor Prefixes的な対応を自動的に行ってくれる関連プラグインをインストール。

cd `プロジェクトフォルダ名`
//cssフレームワークのtailwindcss、postcssのCLI、ベンダープレフィック地獄解消を行うautoprefixer、
npm i tailwindcss postcss-cli autoprefixer

※ インストールしたプラグインは、postcssの設定ファイルに記述して使えるようにします。

touch postcss.config.js //postcssの設定ファイルを手動で作成(プロジェクトルートでOK)

// postcss.config.js
const autoprefixer = require('autoprefixer');
const tailwindcss = require('tailwindcss');

module.exports = {
plugins: [
tailwindcss,
autoprefixer,
],
};

コンポーネントでtailwind.cssを使う準備

// tailwind css関連のライブラリを追加できるための設定ファイルを用意します。
cd src/assets/
mkdir styles/
touch index.css

// src/assets/styles/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

※ maint.s で、上で用意したファイルを読み込めば、コンポーネントでtailwind cssが使えるようになります。

// src/main.ts
...省略
import './assets/styles/index.css'

createApp(App)
.use(store)
...省略

以上で、初期セットアップは済ませました。

アプリを作ってみる

アプリの簡単な説明

  • 以下のような簡単なアプリを作りました。
  • ホームの1番目はカウンターアプリで、2番目は写真ギャラリーです。
  • ヘッダー、カウンターのメイン領域、ギャラリーのカード領域は、コンポーネント単位で用意しました。
  • vuexを導入して赤丸でマークしてる値を他の画面で共有できる例にしました。

アプリのスクショ

作り終えた後のフォルダ構成

作り終えた結果、以下のようなフォルダー構成になりました。 viewsフォルダではルーティングで呼ばれる各画面のトップのコンポーネントのみを置きます。 componentsには再利用可能なコンポーネントを置きます。 components内では、他でも共有できる部品単位、各画面に対する部品単位でサブディレクトリ化します。 Reactも同じですがこうすることで、アプリが大きくなっていても対応してゆけることが良いですね。

作ってる間で、vueの経験がまだ浅いのもあって、実は一番困っていたのは、store/配下のvuexで使うファイル関連の管理です。 小さいアプリならともかく、大きくなっていたときにこれらのファイルが沢山増えていったら元も子もないなって思いました。①で createStoreしてストアを共有しています。なにかいい方法はないかと調べてるうちにこのcreateStoremodules: {②...}とモジュール化したvuexの機構を渡せばよいということを知りました。 これで管理しやすくなりますね。

// src/store/index.ts
export const store = createStore({
modules: {
counterInfo: moduleCounter,
gallaryInfo: modeleGallary
}
})
$ tree src/
src/
├── App.vue // ヘッダーは全画面で共有したいのでここで呼ぶ。
├── assets
├── components
│ ├── counter // カウンターアプリ関連のコンポーネント
│ │ └── Counter.vue
│ ├── gallary // ギャラリー関連のコンポーネント
│ │ └── GallaryCard.vue
│ └── shared // 各画面で共有したい共通の部品
│ └── Header.vue
├── define // TSの型の定義、vuexで使う定数など散らばらないようにここでまとめる。
│ ├── actions
│ │ ├── counter.ts
│ │ └── gallary.ts
│ └── types
│ ├── counter.ts
│ └── gallary.ts
├── main.ts // App.ts、router/、store/ は、ここで登録します。
├── router
│ └── index.ts
├── store
│ ├── index.ts // ① 下記の②、③のように分割されたモジュールをストアを公開する箇所
│ └── modules // ② vuexの機構は各ページ単位でモジュール化して分割
│ ├── moduleCounter // ③ カウンターアプリ用のvuexのモジュール
│ │ ├── actions.ts
│ │ ├── getters.ts
│ │ ├── index.ts
│ │ ├── mutations.ts
│ │ └── state.ts
│ └── moduleGallary // ④ ギャラリーアプリ用のvuexのモジュール
│ ├── actions.ts
│ ├── getters.ts
│ ├── index.ts
│ ├── mutations.ts
│ └── state.ts
└── views
├── Gallary.vue
└── Home.vue

15 directories, 28 files

エントリーポイント main.ts

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { store } from './store'
import './assets/styles/index.css'

createApp(App) //コンポーネントのルートを登録
.use(store) //ストアを登録
.use(router) //ルートを登録
.mount('#app')

コンポーネントのルート App.vue

// src/App.vue の<template>
<template>
<div>
<Header/> //ヘッダーは各画面で共有してます
<router-view />
</div>
</template>

// src/App.vue の<script lang="ts">タグ
import Header from '@/components/shared/Header.vue'

export default {
name: 'App',
components: {
Header
}
}

各ページのトップの親コンポーネント

// src/views/Home.vue の<template>
<template>
<div class="mt-10">
<Counter />
</div>
</template>

// src/views/Home.vue の<script lang="ts">タグ
import Counter from '@/components/counter/Counter.vue'

export default {
name: 'Home Top Page',
components: {
Counter
}
}

※ ギャラリー

defineComponent について

ここで初めて、vue3の新機能Composition APIdefineComponentが出てきます。 これは、コンポーネントのロジックの柔軟な組み立てを可能にする、関数ベースのAPIです。 型推論の改善と、合成関数によるロジックの整理を可能にしてます。 vue2でもサードパーティとして利用できたらしいです。今回は正式に取り込まれたものです。

今までのAPI(Optional API)で使用していたmethodsdatalifecycleなどは、 すべて、このdefineComponentにわたすオブジェクトのsetup()関数内で宣言することが出来ます。 ここ例では、ストアもここで宣言して、ビュー内で使用できるようにしてます。

// src/views/Gallary.vue の<template>
<template>
<div class="w-2/3 h-screen mx-auto flex items-center">
// ↓で公開されたギャラリーのストアをgettersで取得し、そのデータをループして、
// ギャラリーのコンポーネントにデータを渡し、動的にギャリーを描画してます。
<div v-for="gallary of $store.getters.gallaryItems" :key="gallary.id">
<GallaryCard
:id="gallary.id"
:title="gallary.title"
:subTitle="gallary.subTitle"
:description="gallary.description"
:imageUrl="gallary.imageUrl"
:tags="gallary.tags"
:lastTagIndex="gallary.tags.length"
/>
</div>
</div>
</template>

// src/views/Gallary.vue の<script lang="ts">タグ
import { defineComponent, ref } from 'vue'
import { useStore } from 'vuex'
import GallaryCard from '@/components/gallary/GallaryCard.vue'

export default defineComponent({
name: 'Gallary Top Page',
components: {
GallaryCard
},
setup () {
const store = useStore()
const state = ref(store.state) //ストアを使う
return {
state //ストアをビューに公開
}
}
})

ルーティングの定義

const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: Home //ホームのルートコンポーネント
},
{
path: '/gallary',
name: 'Gallary',
// コンポーネントの遅延ロード
component: () => import('../views/Gallary.vue')
}
]

Counter のコンポーネント

// src/components/counter/Counter.vue の<template>
<template>
<div class="bg-gray-300 h64 w-4/6 mx-auto shadow-md flex flex-col justify-center items-center">
<h3 class="text-3xl mt-10 font-semibold">COUNTER APP!! 🔥</h3>
// 公開された関数は、↓ボタンのUIで使われます。
<button
class="bg-red-500 text-blue-900 border rounded-lg px-8 m-4 h-10 text-2xl fornt-bold focus:outline-none"
@click="inc()"
>タップで増加</button>
<button
class="bg-red-500 text-blue-900 border rounded-lg px-8 m-4 h-10 text-2xl fornt-bold focus:outline-none"
@click="dec()"
>タップで減少</button>
<h5 class="text-3l">カウンター数: {{ state.counterInfo.counter }}</h5>
</div>
</template>

// src/components/counter/Counter.vue の<script>
import { defineComponent, ref } from 'vue'
import { useStore } from 'vuex'
import { ActionCounterConst as K } from '../../define/actions/counter'

export default defineComponent({
name: 'Counter Component',
setup () {
const store = useStore()
const state = ref(store.state)

// カウンターアップ(ストアのcounterの値を増やします)。
const inc = () => {
store.commit(K.INCREMENT)
}
// カウンターダウン(ストアのcounterの値を減らします)。
const dec = () => {
store.commit(K.DECREMENT)
}
return {
state,
inc, //ビューで使う関数をビューに公開してます
dec
}
}
})

Gallary のコンポーネント

// src/components/counter/Gallary.vue の<template>
<template>
<div class="px-1">
<div class="max-w-sm bg-white rounded-lg overflow-hidden shadow-lg">
<img
class="w-full"
:src="imageUrl"
alt="sample images"
/>
<div class="px-6 py-4">
<div class="font-bold text-xl mb-2">{{ title }}</div>
<div class="font-bold text-s mb-1">Counter Value: {{ state.counterInfo.counter }}</div>
<p class="text-gray-700 text-base">{{ description }}</p>
</div>
<div class="px-6 py-4">
<span
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2"
v-for="(tag, index) of tags" :key="index"
>#react</span>
</div>
</div>
</div>
</template>

// src/components/counter/Gallary.vue の<script>
import { defineComponent, ref } from 'vue'
import { useStore } from 'vuex'

export default defineComponent({
name: 'Gallary Card Component',
setup () {
const store = useStore()
const state = ref(store.state)
return {
state
}
},
props: {
id: String,
title: String,
subTitle: String,
description: String,
imageUrl: String,
tags: Array,
lastTagIndex: Number
},
methods: {
// 3番目のタグは改行されるスタイルを動的に返却します。
// ビュー側で使いたいのでこのmethodsに登録
getConditionalSpanStyle (spanIdx: number, lastTagIndex: number): string {
const spanIndex = spanIdx + 1
const lastSpanStyle = 'inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700'
if (spanIndex % 3 !== 0) {
return `${lastSpanStyle} mr-2`
}
return lastSpanStyle
}
}
})

Counter のための vuex 関連ファイル

ここではvuex関連のファイルを読み込み、束ねて、一つモジュールとして公開してます。 読み込んでいる各ファイルは、簡単に説明をするとCounterアプリで使うためのvuexの機構を定義しています。

state.tsでは状態を持ち、 getters.tsではstateを返す処理を持ち、 actions.tsではcommitにてmutationsのメソッドを実行する処理の定義があります。 mutations.tsではstateを操作します。

// src/store/modules/moduleCounter/index.ts
import actions from './actions'
import getters from './getters'
import mutations from './mutations'
import { state } from './state'

export default {
state,
actions,
mutations,
getters
}

Gallary のための vuex 関連ファイル

役割は、上記のCounter のと同じです。

// src/store/modules/moduleGallary/index.ts
import actions from './actions'
import getters from './getters'
import mutations from './mutations'
import { state } from './state'

export default {
state,
actions,
mutations,
getters
}

分割したストアをまとめて読み込む

ここで分割したストアをまとめて読み込みます。このようにストア管理できれば、今後ページが増えてきても対応してゆけそうで良いですね。

// src/store/index.ts
import { createStore } from 'vuex'
import moduleCounter from './modules/moduleCounter'
import modeleGallary from './modules/moduleGallary'

export const store = createStore({
modules: {
counterInfo: moduleCounter,
gallaryInfo: modeleGallary //ストアモジュールが増えるごとにここで追加して公開する
}
})

ストアのモジュールのファイルら

ここでは、Counterのみの例を載せてゆきます。

※ アクションの定義

// src/store/modules/moduleCounter/actions.ts
import { ActionCounterConst as K } from '@/define/actions/counter'

const actionIncrement = (context: any) => {
context.commit(K.INCREMENT)
}
const actionDecrement = (context: any) => {
context.commit(K.DECREMENT)
}

export default {
actionIncrement,
actionDecrement
}

※ ゲッターズの定義

// src/store/modules/moduleCounter/getters.ts
import { CounterState } from '@/define/types/counter'

const counter = (state: CounterState) => state.counter

export default {
counter
}

※ ミューテーションの定義

// src/store/modules/moduleCounter/mutations.ts
import { CounterState } from '@/define/types/counter'

const INCREMENT = (state: CounterState, payload: any) => {
state.counter++
}

const DECREMENT = (state: CounterState, payload: any) => {
if (state.counter === 0) return state.counter
state.counter--
}

export default {
INCREMENT,
DECREMENT
}

※ ステートの定義

ここで、counterの初期値を決めます。

// src/store/modules/moduleCounter/state.ts
import { CounterState } from '@/define/types/counter'

export const state: CounterState = {
counter: 0
}

※ モジュール化されたCounterのVuex関連ファイルを公開

上で用意したCounterアプリのためのvuex機構の関連ファイルを同ディレクトリ階層のindex.tsで束ねて公開します。

// src/store/modules/moduleCounter/index.ts
import actions from './actions'
import getters from './getters'
import mutations from './mutations'
import { state } from './state'

export default {
state,
actions,
mutations,
getters
}

こうして、vuexの機構がアプリ上で使用できるようになります。また、新しい画面が今後増えても、上のようにモジュール化してるので、少し冗長というコストは発生しますが、その代わりアプリの可読性やメンテナビリティの向上の貢献に役立たせることが出来そうですね。

まとめ

vue3にはまだまだ新しい機能がありますが、今度はそれらの機能も使った例も作ってみたいと思いました。

Typescriptが正式サポートされることにより、vue2でよく生じると言われてる型推論との意味のない格闘も避けられ、また、vue3自体に大きく変更が加わったことにより、以前と違う見渡しの良いアプリが作れそうな印象を受けました。

新バージョンが出てまだ時期がそんなにたってないというのはありますが、次回、vue3のAPIがさらに安定してることを期待してます。今回の仕様変更は本当に以前よりも安心してある程度大きなアプリでも採用を検討できそうだなと感じました。