Edited at

実録コミット振り返り:CSS Grid Layout GeneratorをVueで作ってみる

More than 1 year has passed since last update.


はじめに

mizchiさんのCSS Grid Layout GeneratorElmでクローンを作っている記事)というのを見て、関数型すごいなあと思いつつ、中身のロジックは置いといて単に見た目同じようなものをVueで作るならどのくらいで出来るかなと興味が湧いたのでベンチマーク的に目コピを試してみました。

結果から言うとこんな感じになりました。

preview

今回記事にまとめるにあたり、特にゼロから立ち上がりの部分を見ていったらいろいろ手順とかつまづきどころとか作業見積もり感とか面白いんじゃないかなーと思うので、一個一個のコミットを拾って振り返ってみようと思います。


振り返り


1stコミット [11:34]

はい、始まりました。

まずVueなんですが、新規にサイト作るならNuxtでいいと思います。routerもstoreもpluginもmoduleもSSRもSPAもgenerateもなんでもこいです。それらが必要無くても必要になったときにすぐ使えます。

$ vue init nuxt-community/starter-template <project-name>

インストール - Nuxt.js の通り、vue-cliでテンプレートからセットアップしたところでとりあえずコミットしました。

ここから目標はー…、とりあえず3時間くらいで完成したいという見積もりです。


2ndコミット [11:45]

いくつか初期設定を追加します。


  • prettier

  • TypeScript

  • SCSS

あたりですね。


prettier

https://twitter.com/Vjeux/status/944979126854848513

去年末のクリスマスプレゼントで.vueのscriptとstyleでもprettierが使えるようになりました。いやーよかったですね。使いましょう。さもなくば死あるのみです。


TypeScript

Nuxtの現行stable版で使われてるVueはv2.5以前のものなのでSFCのthisの型がanyです。じゃあ意味無いじゃん!って感じもしますがゆるく使ってるのでとりあえず入れます。


CSS

scss使うためにsass-loaderとnode-sass入れてます。Vueは楽にscopedにできるのでcssについてはかなり雑に扱っているんですが、buttonとかinputとかデフォルトのままだとUIひどいしいちいちスタイルつけるのがめんどいので、初手でresetしてbaseにおしゃんな感じのものを設定しておくべきだなと思いました。


Netlify

コミットではないですが、プロジェクトをNetlifyに接続しておけばpushするたびに勝手にビルドされてビルドのログも残るので振り返りにもいいかなと思います。


3rdコミット [12:56]

はい。いきなり1時間かかりました。

とりあえず最初の画面が出来たのでここからはビルドしたプレビューもつけて実際の動きを見れるようにします。(とはいえこの時点では動きは無いですが)

そもそもGridLayoutはどうしたらどうなるんだっけというのがよく分かってないので、いったいステートをどう持てばいいのかというところの見定めから作っていきます。たぶん本当はこれだけでもっと時間かかると思うんですが、今回は元ネタの完成形があるのでそのデモの動きを見てこうすればいいんだなというのを決めていきました。

画面構成やUIについても、やはりゼロからだと迷いどころが多いのですが、ブツがあるので非常にやりやすいです。挙動まで見えている完成形をただ書けばいいだけという状況はゴールが明確でいいですね。


親コンポーネント

    <GridLayout

:areas="areas"
:columns="columns"
:rows="rows"
/>

  data() {

return {
areas: [
['header', 'header', 'header'],
['left', 'main', 'right'],
['footer', 'footer', 'footer']
],
columns: ['120px', '4fr', '1fr'],
rows: ['60px', '1fr', '40px']
}
}

まずはグリッド表示コンポーネント作り、そこのpropに対して親コンポーネントからdataでステートを渡します。ステートとして持つべき情報は


  • エリア名配列

  • 行・列のサイズ定義

で、これらがあればグリッド要素とスタイルを構築できるはずです。

このへんいきなりStoreを使うよりは、自分の場合だと複雑度に応じて徐々に責務を移していくやり方です。最初はただの親子階層なのでとりあえずdataとpropですね。


Gridコンポーネント

<template>

<section
class="container"
:style="gridStyle"
>
<div
v-for="(area, i) in flatAreas"
:key="i"
:style="{'grid-area': area }"
>
{{area}}
</div>
</section>
</template>

行 x 列 の2次元配列になっているareasを表示コンポーネントのcomputedで1次元化してv-forで要素を配置します。このステートは最初から1次元で持っていたほうがいろいろ効率的な予感もしますが、よく分からないので安直に2次元で定義しときます。

    gridStyle() {

return {
'grid-template-areas': this.areas.map(area => `'${area.join(' ')}'`).join(' '),
'grid-template-columns': this.columns.join(' '),
'grid-template-rows': this.rows.join(' ')
}
}

style部分もcomputedで用意します。


4thコミット [14:11]

さらに1時間経ちました。

今度は行・列のサイズ変更用のinput欄が外側につきましたね。

前回のコミットで作っていたGridLayoutコンポーネントをGridLayoutContentに改名し、新しいGridLayoutコンポーネントでそれを内包して、外周部にinput欄をつけていってます。

  <div class="grid">

<div
class="columns"
:style="columnStyle"
>
<div
v-for="(column, i) in columns"
:key="i"
:style="'c', i | gridArea"
class="grid-cell"
>
<input
type="text"
v-model="columns[i]"
>
</div>
</div>
<div
class="rows"
:style="rowStyle"
>
<div
v-for="(row, i) in rows"
:key="i"
:style="'r', i | gridArea"
class="grid-cell"
>
<input
type="text"
v-model="rows[i]"
>
</div>
</div>

<GridLayoutContent
:areas="areas"
:columns="columns"
:rows="rows"
class="content"
/>

</div>


5thコミット [14:30]

前回のコミットでコンポーネントが階層化されたことで、root → GridLayout → GridLayoutContentと、propsのバケツリレーが発生しました。というわけでここでVuexの出番ですね。dataをstateに移し、それを操作するmutationsを作ります。

export const state = () => ({

areas: [
['header', 'header', 'header'],
['left', 'main', 'right'],
['footer', 'footer', 'footer']
],
columns: ['120px', '4fr', '1fr'],
rows: ['160px', '1fr', '80px']
})
export const mutations = {
areas(state, payload) {
state.areas = payload
},
columns(state, payload) {
state.columns = payload
},
rows(state, payload) {
state.rows = payload
},
column(state, { index, value }) {
state.columns.splice(index, 1, value)
},
row(state, { index, value }) {
state.rows.splice(index, 1, value)
}
}


6thコミット [14:50]

このコミットでは結合された領域を分割するボタンを追加しました。


そもそも結合しているかどうか

分割ボタンを出すということはareaが結合しているという判断が必要です。

      <button

v-if="isMultiple(area)"
@click="breakArea(area)"
>break</button>

ボタンはv-ifに設定したisMultipleの条件で表示させます。

  computed: {

...mapState(['areas', 'columns', 'rows']),
flatAreas() {
return this.areas.reduce((prev, curr) => [...prev, ...curr])
},
areaCount() {
return this.flatAreas.reduce((map, area) => {
map[area] = map[area] ? map[area] + 1 : 1
return map
}, {})
}
},
methods: {
isMultiple(area: string) {
return this.areaCount[area] > 1
}
}

isMultipleは、state.areas→flatAreas→areaCountと加工してMapを作り、そこにarea名を入れて領域が2つ以上あればtrueを返す感じですね。このへんvueはcomputedの重ね掛けをしてその場その場で必要な情報へと抽象化させていきやすいので考えるのがすごくラクだなあと思います。


結合エリアの分割処理

Grid Layoutのareaは "header header header" のように同名な部分はまとまった領域となります。これを分割するということはつまりそれぞれ異なる名前に変えればいいわけですね。

  breakArea(state, { area }) {

let i = 0
state.areas = state.areas.map((row) => row.map((col) => (col === area ? `${col}-${i++}` : col)))
}

とりあえず全areaに対して、指定のarea名と同じだったら末尾に番号振って "header-0 header-1 header-2" と言った感じに改名していく処理をいれました。(ここでは、先にheader-0が存在しているケースなどは考えません。なぜなら、考えたくないからです。忘れましょう)


8thコミット [15:27]

今回はarea名を変えられるようにします。とりあえずinput欄をつけました。

      <input

type="text"
:value="area"
@input="renameArea({oldValue: area, newValue: $event.target.value})"
>

リネームはただ新しい値を投げるだけではダメですね。

stateに保持されているareaに対して、変更前、変更後の名前を渡して置換していく必要があります。


10thコミット [16:14]

さっきのinput欄を、テキスト表示からクリックされたら入力欄になるようにスマート化します。これは元ネタのUIに寄せる形ですね。わりとこういうところのUI、仕様を考えるところで手間取りがちだなと思うのでお手本あると速いですね。


FocusInputコンポーネント

<template>

<span
@click="onInputStart"
>
<input
type="text"
:value="value"
v-if="focused"
ref="input"
@blur="onInputComplete"
@keydown.enter="onInputComplete"
@keydown.esc="onInputCancel"
>
<span v-else>{{value || '(noname)'}}</span>
</span>
</template>

個別に状態を持たないといけないのでコンポーネント化します。input要素とテキスト要素を併せ持ち、クリックされたらinput要素に切り替えする責務です。フォーカスが外れるかenter, escキーで編集終了して親要素に変更内容をemitし、テキスト要素に戻します。

  methods: {

onInputStart() {
this.focused = true
this.$nextTick(() => this.$refs.input.focus())
},
onInputComplete(e) {
if (!this.focused || e.target.value === '') return
this.focused = false
this.$emit('input', e)
},
onInputCancel() {
this.focused = false
}
}

フォーカスが外れたら編集終了としたいため、クリックしたときにはフォーカス済みにしておきたいのですが、親要素のクリックによって初めて子要素のinputが出現するためその場ではinputにフォーカスがきかないようです。どうすればいいのかー。

ググりましょう。ググりました。ここでは$nextTickのコールバック内でフォーカス処理を呼ぶことで対処します。


11thコミット [16:56]

行と列の追加・削除ボタンがつきました。

  pushColumn(state) {

state.columns.push('1fr')
const c = state.columns.length - 1
state.areas = state.areas.map((area, r) => [...area, `a-${r}-${c}`])
},
popColumn(state) {
state.columns.pop()
state.areas = state.areas.map((area) => area.slice(0, -1))
},
pushRow(state) {
state.rows.push('1fr')
const r = state.rows.length - 1
state.areas.push(Array.from(new Array(state.columns.length).keys()).map((c) => `a-${r}-${c}`))
},
popRow(state) {
state.rows.pop()
state.areas = state.areas.slice(0, -1)
}

追加削除時には、行・列のサイズ定義の変更と、そこにマッピングされているareaについても変更を加える必要があります。サイズ定義のほうは1次元配列なのでpush/popするだけです。けれどareaのほうは2次元配列なのでちょっと一手間加えるのと、追加時には新規のarea名を設定する必要があります。

この段階では行・列の座標値を使ってa-2-4といった名前にしていましたが、あとで任意の行・列に追加できるようにする対応をした際に、座標だと名前が被ってくるのでハッシュ値を使うようにしました。


12thコミット [17:37]

クリックでエリアの選択・解除ができるようにしました。

エリアの分割をしたからには結合もしたいわけで、そのためにまず選択状態をステートに追加しました。しかしそこからどういうUIにしていくべきかというのが悩みどころです。なぜ悩むのかといえば、分割と結合については元ネタの方には無い機能だからです。やりたくなってしまったからしょうがないです。

というわけで、3時間目標という話だったんですが、気づいたら6時間近く経ってしまいました。もはや道が逸れてしまいました。とりあえず元ネタの機能を実装するという点についてここで振り返ってみると、先程の行の追加削除対応のところで一応出来たんじゃないかという感じです。

でもまだアウトプット用のCSSとHTMLの表示とか、レイアウトの保存・復元機能ができてないんですが、それはVuexに入ってるからもう出来てるようなものだという気持ちです。気持ちが大事です。

(Vuexで確認できてるからOKという認識)


13thコミット [19:09]

はい、結合ですね。

このへんはもう、ちょっとグダグダになってる感あります。

とりあえずUIとしては先程の選択状態を用いて「選択エリアが矩形になっていたら結合可能とする」という判定を入れて、結合ボタンを表示するようにしました。

export const getters = {

isCombinable: (state, getters) => {
if (getters.selectedAreaKeys.length < 2) return false

const c = state.columns.length
const minCoords = getters.selectedAreaKeys.map((selectedArea) =>
getCoords(c, getters.flattenAreas.indexOf(selectedArea))
)
const maxCoords = getters.selectedAreaKeys.map((selectedArea) =>
getCoords(c, getters.flattenAreas.lastIndexOf(selectedArea))
)
const min = minCoords.reduce((prev, curr) => [
Math.min(prev[0], curr[0]),
Math.min(prev[1], curr[1])
])
const max = maxCoords.reduce((prev, curr) => [
Math.max(prev[0], curr[0]),
Math.max(prev[1], curr[1])
])

for (let row = min[1]; row <= max[1]; row++) {
for (let col = min[0]; col <= max[0]; col++) {
if (!state.selectedAreaMap[state.areas[row][col]]) return false
}
}
return true
}
}

結合可能かどうかの真偽値を返すgetterがこれですね。選択中のエリアの座標から最小座標と最大座標を割り出して、その間全部選択エリアになってるかどうかという考えで矩形判定を行ったのですが、矩形が離れて二つある場合とか考慮してません。あと普段for文書くことは無いんですがどうにも使わざるを得なくて筋が悪い感じです。

export const mutations = {

combineArea(state, { area }) {
state.areas = state.areas.map((row) =>
row.map((col) => (state.selectedAreaMap[col] ? area : col))
)
state.selectedAreaMap = { [area]: true }
}
}

で、結合ボタンを押すと選択エリアを全部指定のエリア名にリネームする処理が行われ、同一の名前になることで結合となります。


16thコミット [20:49]

はい。この日最後のコミットです。

行・列の最後にしか追加削除できなかったのを、どこでも追加削除できるようにしました。

しましたと言ってもここでは問題があり、結合したエリアをまたぐ場合にはちゃんと分割してあげないと表示がぶっこわれます。その辺の処理が地味に面倒だったのでこの日はここまでとなりました。


この後の展開ダイジェスト


アウトプットのcss, html表示欄つけた


スタイル整えた


レイアウトの自動保存、インポート・エクスポートつけた


カラムのサイズをドラッグで変えられるようにした


gridコンテナのサイズを設定できるようにした

という感じです。


まとめ


  • 複雑なレイアウトも作れる :innocent:

  • けど、grid内にgrid作るべきだと思う :weary: