Vueと共に走ったフロントエンドリプレイス1年間

Dc4bd28d5c19ca3ad91bdc546854df8e?s=47 komiyamast
January 30, 2019

 Vueと共に走ったフロントエンドリプレイス1年間

【Nuxt.js/Vue.js】スタートアップ企業導入事例
https://re-build.connpass.com/event/111146/

Dc4bd28d5c19ca3ad91bdc546854df8e?s=128

komiyamast

January 30, 2019
Tweet

Transcript

  1. Vueと共に走った フロントエンドリプレイス 1年間 2019/01/30 【Nuxt.js/Vue.js】スタートアップ企業導入事例 小宮山 智也

  2. 目次 • 自己紹介 • 概要 • 技術構成 • コンポーネントとルーティング設計 •

    データフロー設計 • テスト • 1年かけた学び • おまけ
  3. 自己紹介 • 小宮山智也(Komiyama Tomoya)@株式会社スタディスト • 最近の趣味 ◦ ランニング ◦ ウォーキング

    ◦ ボルダリング • 近況報告 ◦ VSCodeからNeovimに乗り換えた 2017年10月 2018年11月
  4. 概要

  5. 伝えたいこと概要 • Vue関連技術をフルに活用した設計と実践の実例 ◦ 破綻させずにリリースまで辿り着いた成果の共有 ◦ Atomic Design ◦ ドメイン駆動Vuex

    ◦ コンポーネントが守るべきこと • これは良かった悪かったの振り返り • Storybookの素晴らしさ
  6. 今回は伝えないこと概要 • 技術選定のこと • 開発プロセス的なこと • UIUXの設計や検討的なこと • バックエンドのこと

  7. プロジェクト概要 • Teachme Biz(https://biz.teachme.jp/ )というプロダクトのWeb版UI全面リニュー アル • ローンチから積み上げ続けてパンク気味だったフロントエンドを一気にモダン化する • 継ぎ接ぎになってしまったUIUXを根本から見直す

    • Before: RailsのERBを使った古き良きRails主体のフロントエンド • After: Railsと切り離したフロントエンド主体のフロントエンド
  8. 新旧比較 https://biz.teachme.jp/function/ui-renewal-2018/

  9. 技術構成

  10. 概要 • 開発環境 ◦ Docker、Webpack(3系)、Babel、Eslint、Sass、PostCSS • テスト ◦ Karma、Mocka、power-assert、Storybook •

    フレームワーク ◦ Vue、Vuex、vue-router • 使っていない/やっていないもの ◦ Nuxt、TypeScript、Webpacker、E2E
  11. 利用パッケージ① "dependencies": { "@storybook/addons": "", "axios": "", "chart.js": "", "encoding-japanese":

    "", "es6-promise": "", "jquery": "", "jquery-ui": "", "marked": "", "pdfjs-dist": "", "raphael": "", "ress": "", "storybook-router": "", "vue": "", "vue-analytics": "", "vue-chartjs": "", "vue-i18n": "", "vue-loader": "", "vue-router": "", "vue-slider-component": "", "vue-template-compiler": "", "vue-touch": "", "vuedraggable": "", "vuejs-datepicker": "", "vuex": "", "webpack-merge": "", "zxcvbn": "" }, • deb or 非debの区分けは適当 ◦ ライブラリとして配布するわけではな いので気にしていない ◦ webアプリとしていい感じの運用方法 があるなら知りたい • jqueryはリプレイスしきれなかった 過去実装の残骸
  12. "eslint-plugin-html": "", "eslint-plugin-import": "", "eslint-plugin-node": "", "eslint-plugin-promise": "", "eslint-plugin-standard": "",

    "eslint-plugin-vue": "", "extract-text-webpack-plugin": "", "file-loader": "", "jsdom": "", "json-loader": "", "karma": "", "karma-chrome-launcher": "", "karma-firefox-launcher": "", "karma-ie-launcher": "", "karma-jsdom-launcher": "", "karma-mocha": "", "karma-mocha-reporter": "", "karma-opera-launcher": "", "karma-safari-launcher": "", "karma-webpack": "", "devDependencies": { "@storybook/addon-actions": "", "@storybook/addon-knobs": "", "@storybook/addon-links": "", "@storybook/vue": "", "aglio": "", "axios-mock-adapter": "", "babel-core": "", "babel-eslint": "", "babel-loader": "", "babel-plugin-espower": "", "babel-plugin-transform-runtime": "", "babel-preset-env": "", "css-loader": "", "drakov": "", "eslint": "", "eslint-config-standard": "", "eslint-config-vue": "", "eslint-friendly-formatter": "", "eslint-loader": "", 利用パッケージ② "mocha": "", "node-sass": "", "postcss-cssnext": "", "postcss-smart-import": "", "power-assert": "", "sass-loader": "", "style-loader": "", "url-loader": "", "vue-style-loader": "", "webpack": "", "webpack-dev-server": "", "webpack-manifest-plugin": "" }
  13. コンポーネントとルーティング設計

  14. Atomic Design - src - components - atoms - molecules

    - organisms - pages - routerLayouts • ディレクトリ構成は右図 • routerLayoutsは独自のもの(後述) • templatesは設けていない ◦ SPAだとページ内容は動的に流し込むので、 pagesと実質 同じという記事をどこかで読んだ(見失った) • コンポーネントの粒度とディレクトリの表示順が一 致するのが地味に有効
  15. atoms • コンポーネントの最小単位 ◦ ボタン、テキストインプット、ラジオボタン • Vuexへのアクセス不可

  16. molecules • atomsよりも大きめの単位 ◦ ドロップダウン付きのボタン、パンくずナビゲーション、スナックバー • organismsほど大きくないけれどatomsというほど小さくな いことが判断基準になることが多い • Vuexへのアクセス不可

  17. organisms • moleculesよりも大きく、画面レイアウトのパーツとなる ような単位 ◦ ダイアログ、ヘッダー、テーブル • Vuexへのアクセス解禁

  18. pages • 1画面となるような単位 ◦ ログイン画面、ユーザープロフィール画面 • Vuexへのアクセス可 • その画面のmain処理 ◦

    データfetchの起点
  19. routerLayouts • 画面レイアウトの枠組み • 中身はvue-routerから挿入する ◦ <router-view>をtemplateに設置する • Atomic Designの要素ではない

    ◦ 画面の共通レイアウト置き場が欲しかった
  20. レイアウト構成 • App.vueが全ての画面ルート ◦ 共通部分だけを設置 ◦ 個別画面の処理には干渉しない • 画面に応じたレイアウトをrouterが App.vueに挿入

    • 画面に応じたパーツをrouterがレイア ウトに挿入
  21. App.vue レイアウト構成 organisms routerLayouts routerが組み立てる pages

  22. 良かったこと • 必ずApp.vueがルートコンポーネントになる ◦ 全画面で共通する処理を組み込みやすい ▪ フラッシュメッセージ、アラートダイアログ、ローディング • レイアウトを統一できる ◦

    レイアウトに関わるCSSが散在しない ◦ レイアウト種類は想像よりも少ない • ファイル数が減る(?) ◦ 共通処理化、レイアウト統一の恩恵 ◦ (調べたら.vueファイルが102あった、画面数は77、全然減ってない)
  23. 悪かったこと • ルーティング設定が肥大化する ◦ 「設定ファイル」を逸脱しないよう頑張っているが、やはり行数が多いのは辛い • ルーティング設定の書き方が、コンポーネントの階層構造に引きずられる • 画面の基点となるコンポーネントを意識していなかった •

    レイアウト用コンポーネントが<router-view>利用前提になってしまった
  24. ルーティング設定の肥大化 • 77画面で1473行のルーティング設定 • 右図のように、ルーティングのネストはURL ではなく、コンポーネントの階層で区切らな ければならない ◦ レイアウト→パーツという階層なので、必ず 2回層

    書く必要がある ◦ URLの階層と比べて、コンポーネントの階層はあ まり直感的ではない
  25. 画面の基点 • contentが実質的に基点となっている ◦ 初期データfetchもここで行っている ◦ 元々の設計意図ではなく、なし崩し的にそうなった ◦ ルーティングに依存させたくない意識があった •

    各パーツを跨ぐ処理が難しくなる ◦ headerなどはrouterにより分離されているので、 contentから 制御できない ◦ pluginやstoreなどグローバルな存在に頼らざるを得ない ◦ 例) ▪ パワポのようなサイドメニューでパネルを選んだら、メイ ン部分をそこまでスクロールさせる App.vue
  26. • contentを画面の基点と認める ◦ pagesは画面の基点置き場とする ◦ ディレクトリ構造をルーティングに合わせる ◦ 実質そうなりつつあるが、設計の前提にしていたらもっと良く なったと悔やまれる •

    共通レイアウトを見極める ◦ パーツまではめ込んだものをレイアウトにする ◦ content部分だけをslot(router-view)ではめ込む ◦ contentと連動していると感じたら共通レイアウト化はさっさ と諦める ◦ 複雑でダイナミックな画面も共通レイアウト化はさっさと諦め る こうすればもっと良くなったかも(未検証) slot - pages - folders - FolderList.vue - FolderProfile.vue - users - UserList.vue - UserProfile.vue
  27. App.vue レイアウト構成改善案(未検証) routerLayouts slot slot slot pages 各種パーツはorganismsに設置 枠組み自体もコンポーネントにす るのが良さそう

    画面の基点はここ その他は干渉できない共通パーツ
  28. これってもしかして...

  29. Nuxt式ルーティング • 画面ごとに基点コンポーネント • ディレクトリ構造によってURLを表現 ◦ 非Nuxtではルーティング自動生成はできないが、設計の参 考にしない手はない • レイアウト構成はまさしく改善案

    ルーティング設計はNuxtから学ぶべし (たとえNuxtを導入しないとしても)
  30. レイアウトの<router-view>ロックイン • 共通レイアウトで作ったが、共通にできない独自な画面であることが後から判明す ることもある ◦ 例)contentとsidebarを跨ぐ制御が必要になったことが実際にあった ◦ 共通の親がいればpropsと$emitで制御できるのに、その親がいない ▪ <router-view>で分断されてしまっている

    • 共通レイアウトから分離するコストが高い ◦ 独自制御はあるが、見た目のレイアウトは共通と同じ ◦ <router-view>が<slot>にさえなっていれば使い回せる(はず) ▪ → レイアウトを常に<slot>で作り、それを<router-view>でラップする存在がいたら解決した のではないか?
  31. レイアウトコンポーネント改善案(未検証) レイアウトは常にslotで作成 router経由でしか使えない レイアウトになってしまう 共通パーツやrouter-viewをslotに 埋め込むだけのコンポーネントを用 意する

  32. ディレクトリ構造改善案(未検証) • <slot>利用の純粋なスタイル付きレイアウトを organismsに置く • pagesには各画面のメインとなるコンポーネントを ルーティングのネストに沿って置く • レイアウトを利用し、<router-view>や共通パーツを 配置した存在をpageLayoutsに置く

    ◦ <style>は不要 ◦ <script>は最低限 - organisms - layouts - HSCLayout.vue - HCLayout.vue - pages - folders - FolderList.vue - FolderProfile.vue - users - UserList.vue - UserProfile.vue - routingLayouts(pageLayouts) - MainLayout.vue - settings - FolderLayout.vue - UserLayout.vue
  33. データフロー設計

  34. ドメイン駆動Vuex • Vuexをドメインの処理に集中させる ◦ https://medium.com/studist-dev/ddd-vuex-c47055f6c1ba ◦ ドメインの複雑さはVuexに閉じ込める • コンポーネントは理想的なモデルをVuexから受け取り、豊かなユーザー体験の表 現という本来の仕事に集中する

  35. Vuexフロー要約 • コンポーネントからaction実行 • actionはajaxでAPIにリクエスト • レスポンスをmutationに受け渡す • stateにAPIデータを保管 •

    getterでモデルに整形してコンポーネン トへ受け渡す
  36. 非ドメインなデータ • 全てのデータがドメインとは限らない ◦ APIからもらえるものはほぼドメインとみなして問題ない • 画面都合で一時的に保持したい状態がある ◦ 通信中のローディング表示をおこなうための状態 ◦

    エラーメッセージを表示するための状態 • Vuexはドメインデータに完全特化させるため、非ドメインなデータはstoreに保管し ない ◦ 単一storeというFluxの理想を追いすぎない
  37. 非ドメインなデータ置き場 • 各コンポーネントのdata ◦ コンポーネントに閉じるようなデータならばここに置くのが原則 ◦ 一覧データのページネーション、ドロップダウン開閉状態、保存前のフォーム入力など • Vueのplugin機能 ◦

    コンポーネントを跨ぐようなデータはここに置く ◦ モーダルなローディング状態、アラートメッセージなど • コンポーネントのレイアウト構成に失敗すると、その画面でしか使わないのに pluginに状態を置かざるを得ない状況が生まれてしまう ◦ レイアウト構成を見直すべし
  38. 例)アラートダイアログ① • アラートダイアログを表示するため の状態をpluginに置く • pluginとして読み込んでいるので、 コンポーネントから下記のように呼 び出せる

  39. 例)アラートダイアログ② • コンポーネントの実体はApp.vueに設置しておく ◦ App.vueは常にルートなので、どのコンポーネントからでもアラートを呼び出せる App.vueのcomputed App.vueのtemplate

  40. アラートダイアログ余談 • アラートダイアログのコンポーネントをApp.vueではなく、呼び出す側のコンポーネ ントに設置すればpluginではなくdataに状態を持たせるだけで済む ◦ Vuetify式 ◦ 初期はこの方針で実装していたが、あまりにも同じコードを量産することになったので plugin利用に 方針変更した

    ◦ モーダルなローディング、 Confirm(Yes/No)ダイアログも同様の実装方針 • Vue Materialもプラグインで手軽に呼び出せる実装になっている
  41. - store/ - folders/ - users/ - actions.js - actionTypes.js

    - getters.js - getterTypes.js - index.js - mutations.js - mutationTypes.js - users/ - folders/ - index.js store構成 • ディレクトリ構成例は右を参照 ◦ ドメインごとにディレクトリを作る ◦ 従属リソースはディレクトリをネストする ◦ モジュール毎のファイル構成は同一 • モジュール数は24だった ◦ 画面ごとにモジュールを作るよりは少なく済んだはず ◦ 画面で忠実に分けたなら 77必要だったかもしれない
  42. 従属リソース • フォルダに所属しているユーザーを folders/users というモジュールに切り分けて いる ◦ ユーザーというドメインに従うなら、 usersに全てまとめるべきかもしれない •

    分けているのは実践的な理由 ◦ フォルダ所属ユーザー一覧画面では、所属ユーザーの一覧と、所属させたい候補ユーザーの一覧 を同時に表示する必要がある →「ユーザー一覧」が2つ必要 ◦ 他の従属リソースも大抵同様の画面がある ◦ usersにまとめると、「ユーザー一覧」が usersのstateに大量発生してしまう ◦ 付随してactionやmutationも増えていく ◦ 切り出してしまったほうが見通しが良い
  43. store定数 • actionTypes、getterTypes、mutationTypesは関数名の定数定義 ◦ actionは永続化データの変更を伴うので、 CREATE/DELETE/UPDATEなどの命名 ◦ mutationはstore内データの変更なので、 ADD/REMOVE/UPDATEなどの命名 ◦

    getterはドメインモデル名そのまま(右参照) • 良かったこと ◦ 利用箇所をgrepで探しやすい • 悪かったこと ◦ ファイル数とimportが増える ▪ 1ファイルにまとめてみる ▪ 右のようなtypes.jsを設置してみる
  44. 一覧データと詳細データ • データ置き場は分けている(右図) ◦ 一覧 = users#index ◦ 詳細 =

    users#show • モデルも異なるものとして扱う ◦ 一覧をベースに、プロパティを追加したものが詳細 ▪ 一覧:id、name、icon、createdAt… ▪ 詳細:一覧+α(自己紹介とか) 一覧 詳細 一覧 詳細 V.S.
  45. 一覧データと詳細データのgetter例 • フォルダ一覧と詳細のモデルを返すgetter実装 ◦ getterはstateの内容をモデルに変換して返すだけ → ドメイン駆動Vuex ◦ モデル変換は従属リソースなど他の場所でも頻出なので util化しておく

  46. 一覧画面から詳細画面への遷移 • 一覧画面 ◦ usersをfetch ▪ ページネーションやソート情報はコンポーネントが管理 • URLクエリとの連携もコンポーネント側で管理 ▪

    ページネーションなしの総数( users_size)はstateに保管 ◦ 詳細表示したいユーザーを選択したら、その idと共に$router.push • 詳細画面 ◦ routerからprops経由でidを受け取る ◦ userをfetch ◦ stateには一覧が残ったままだが気にしない ◦ userに更新を加えても一覧側には反映しない ▪ 一覧画面に遷移したら usersのfetchが常に走らせる 初期データfetchは遷移先画面の役割 => 画面リロードしても同じ処理で済む
  47. 詳細データfetch工夫の歴史(初期) • actionはAPIにリクエストし、レスポンスをmutationに受け渡す ◦ ドメイン駆動Vuex • 問題 ◦ stateには前回取得した詳細データが残っていることがある ◦

    新規の詳細データfetchが完了するまでの間、前回の詳細情報が画面に表示されてしまう (gif)
  48. 詳細データfetch工夫の歴史(中期) • 新規の詳細データfetchを行うときに、前回データのクリアを行った ◦ 前回データが表示される問題は解決 • 問題 ◦ 詳細データが必要な画面は複数存在したりする ◦

    それらの画面間で遷移した場合、毎回詳細データクリアが挟まり UXが悪い ▪ コンポーネントはv-ifで描画制御を行っていたりするため stateクリアmutation (gif)
  49. 詳細データfetch工夫の歴史(現在) • 詳細データが既に取得済の場合、データクリアをしない ◦ 詳細データfetchは続行し、完了したら無言で差し替える ◦ 更新されていない情報が画面に表示される可能性はある ▪ fetch完了までの一瞬&完了したら差し替わるので気にしない •

    一瞬で済まないのなら APIを高速化! stateクリアをするかの分岐 (gif)
  50. 詳細データfetchのコンポーネント側 • getterからモデルを取得 • モデルロードをactionに要求 ◦ mountedなどで実行する • ロードやアラートなど、画面都合の処理はコンポーネントが担当 •

    アンダーバー付き命名は独自ルール ◦ 見分けやすい ◦ 基本的に直実行はしない ◦ 前後処理をラップした methodを作る ◦ (Vue的にはアンダーバー非推奨かも)
  51. テスト

  52. ユニットテスト • Karma、Mocka、power-assertで構成 • Vuexのactions、getters、mutationsは網羅 • utilとして切り出した処理は網羅 • .vueファイルはやっていない ◦

    開発初期にわりきってしまった ◦ storybookで頑張る方針 ◦ 難しい処理は書かない &utilに切り出してテスト ▪ 権限判定、バリデーションなど
  53. コンポーネントテスト • Storybookで.vueファイルを網羅 ◦ 入力(props)による分岐はなるべく網羅 • 全画面をモックで表示 ◦ バックエンドなしの完全モック ▪

    独立したフロントエンドの実現 ◦ knobを使って権限やプランを切り替え可能にした ▪ 全パターン網羅は辛すぎた ▪ せめて切り替えて任意の条件を再現できるようにした
  54. Storybookスクショ

  55. Storybookモック動作 (gif)

  56. How: Vuexモック • APIとの対話はVuexに集約されている ◦ ドメイン駆動Vuex ◦ VuexをモックできればAPIを丸ごとモックしたことになる • コンポーネントとVuexのインタフェースはactionsとgettersのみ

    ◦ actionsは return Promise.resolve() すればいいだけ ▪ モックなので全部正常系にしてしまう ▪ 非同期間が欲しければ setTimeout を挟む ▪ 一部 resolve でデータを返している actionがあったので、それは都度対応が必要 ◦ gettersはモックのモデルを返せばいいだけ • 最初は手間だが、storeの丸ごとモックを一度作ってしまえば全画面で使うことがで きる
  57. How: vue-routerモック • storybook-routerを使う ◦ https://github.com/gvaldambrini/storybook-router • ルーティング設定をゴニョゴニョしていい感じに使う

  58. 1年かけた学び

  59. 闇のextends V.S. 薄闇のmixins • コンポーネントは親に対してpropsと$emitというインタフェースを公開している • その他のdata、computed、methodsは全てprivateな存在 • extendsはこの原則に横穴を開けてしまう ◦

    継承されていると知らずに修正したことで不具合にしばしば遭遇した ◦ コンポーネントは閉じた存在という意識が強いため、横穴に気付くのが非常に難しい • mixinsであれば、横穴が存在することが前提なので危険が減る • どちらも使わないに越したことはない ◦ slotで表現できないか十分に検討するべき ◦ slotを制する者はVueを制する ◦ どうしても無理なら最後の手段で mixins
  60. 1年かけた学び • コンポーネントは自己完結しているべき ◦ 親とのインタフェースである propsと$emit以外はいつでも変更できるのが理想

  61. 禁断の /deep/ • scopedなスタイルを外側からこじ開けて上書きできる禁術 ◦ https://vue-loader-v14.vuejs.org/ja/features/scoped-css.html • 使っていいシーンは非常に少ない ◦ 外部ライブラリによるコンポーネントスタイルをどうしても変更したいとき

    ◦ v-htmlなどで動的にdom生成してスタイルを当てたいとき • 軽い気持ちで絶対に使ってはいけない ◦ 「この画面でだけマージンがちょっと違うから /deep/で変えよう」→NG ◦ 「フォントサイズだけ変えれば使い回せるから(ry」 →NG ◦ 「色が違うだけ(ry」→NG • どんなに些細なスタイル変更であっても、propsによる明示的なインタフェースを公 開しておくことが世のため人のため
  62. 1年かけた学び • コンポーネントは自己完結してこそ美しい ◦ 親とのインタフェースである propsと$emit以外はいつでも変更できるのが理想 • scopedなスタイルは神聖かつ不可侵である ◦ ルート要素は例外

  63. 魔性の $refs • $refs自体は悪いものではない ◦ エレメントからgetBoundingClientRectしたいときだってある • $refsを通してdataやcomputedに触るべきではない ◦ それはプライバシーの侵害

    ◦ 先頭を大文字にしたからって publicにはならない ◦ 多少汚い実装になってでも $emitや.syncを駆使して避ける • $refsを通してmethodsに触るべきではない ◦ それはプライバシーの侵害 ◦ 先頭を大文字にしたからって publicにはならない ◦ 関数に切り出してutil化できるはず
  64. 1年かけた学び • コンポーネントは自己完結してこそ美しい ◦ 親とのインタフェースである propsと$emit以外はいつでも変更できるのが理想 • scopedなスタイルは神聖かつ不可侵である ◦ ルート要素は例外

    • 親が子のdata、computed、methodsにアクセスしてよい場面はない ◦ アクセスする手段はあるがそれは地獄の入口
  65. 1年かけた学び • コンポーネントは自己完結してこそ美しい ◦ 親とのインタフェースである propsと$emit以外はいつでも変更できるのが理想 • scopedなスタイルは神聖かつ不可侵である ◦ ルート要素は例外

    • 親が子のdata、computed、methodsにアクセスしてよい場面はない ◦ アクセスする手段はあるがそれは地獄の入口 コンポーネントの外部インタフェースは propsと$emitだけであることを徹底する
  66. おまけ

  67. シャイなダイアログ • App.vueに共通で設置した以外のダイアログは使いたい場所で設置する • 最前面に表示できないときがある ◦ 無慈悲なfixedやz-indexに阻まれやすい ◦ 特にsafari •

    domをbody直下に突っ込んでしまう ◦ コンポーネントフレームワークも普通にやってる ◦ 片付けは完璧に行う必要あり ◦ ドロップダウン系もやってしまうと楽 巷のコンポーネントフレームワークも普通にやっている https://github.com/vuematerial/vue-material/blob/4fa84a4005484563632c898c14974f65eaaca916/src/components/ MdPortal/MdPortal.js#L133
  68. ご静聴ありがとうございました