20210105のvue.jsに関する記事は3件です。

年末年始にやってみたチュートリアルまとめ

気になっていたけどやれていなかったチュートリアルを年末年始にまとめてやってみた。その備忘録。

Summary

キーワード やってみたチュートリアル チュートリアル感想
TypeScript Learn TypeScript in 30 Minutes TypeScriptの概要をさらっと掴むのにおすすめ。
GraphQL HOW TO GRAPHQL graphql-node Tutorial GraphQLのサーバサイド視点のチュートリアル。nodejs版。他にもruby版、java版など各種言語向けがある。nodejs版ではApolloサーバとPrismaを使ってGraphSQLサーバを作って見ることができる。途中まで分かりやすかったが、後半(Subscriptionの説明あたりから)が作りかけっぽく、説明が飛んでいたり、間違っていたりするので注意。
Terraform 実践Terraform AWSにおけるシステム設計とベストプラクティス AmazonでKindle版を購入。よく使うAWSリソースのTerraformでの構築例が豊富に掲載されている。Terraformを使うときにはそばに置いておきたい本
Terraform vs Serverless Framework A Beginner's Guide to Terraform and Serverless TerraformとServerless Frameworkの使い所の解説記事。
Serverless Applications with AWS Lambda and API Gateway TerraformでのLambdaのデプロイ方法。Serverless Framework版と比較すると特徴が見えてくる。
Serverless Framework - AWS Lambda Guide - Quick Start Serverless FrameworkでのLambdaのデプロイ方法。Terraform版と比較すると特徴が見えてくる。
CodeIgniter4 Build Your First Application -CodeIgniter 4.0.4 documentation- PHPフレームワーク CodeIgniter4でのMVCにもとづく実装方法が一通り理解できる。
CodeIgniter4でREST APIを作成する CodeIgniter4のResourceControllerでREST APIを実装する方法。
CodeIgniter3 CodeIgniter Composer Installer CodeIgniter4のチュートリアルで学んだことをCodeIgniter3でやって見るときに便利。REST APIの実装サンプルもあり。
CloudFormation CloudFormationのヘルパースクリプトcfn-initによるインスタンスの初期化 - Developers.IO CloudFormation再入門。cfn-initがよく分かっていなかったので改めて。記事の内容をYAML形式に置き換えてやってみた。
AWS CloudFormationで使える4種類のヘルパースクリプトについて使い方と機能をまとめてみた - Developers.IO cfn-init以外のヘルパースクリプトの説明。
Vue Vue.js&Nuxt.js超入門 AmazonでKindle版を購入。VueとNuxtの基本が分かりやすく解説してある。前半部分だけ読んでこれが作れた。
Netlify Vue.js+Netlifyで自動デプロイ -基礎から学ぶVue.js- Vueで作ったアプリを無料でインターネット公開する方法。githubと連携したCI機能も提供しておりとても便利。

備忘録や感想

ふだんサーバサイドの開発に携わっているエンジニアとしての超概要と感想。

TypeScript

超概要

静的型付けの機能などをJavaScriptに追加したもの。大規模開発をJavaScriptでやるのがつらくなってきたからMSが開発した。
実行前にTypeScriptからJavaScriptに変換=トランスパイルする。tscコマンドでできる。トランスコンパイル後のJavaScriptも可読性があるのがうれしい。
記法は、最近さわってみたKotlinに似ていた。変数名の後に型名を記載するあたりでそう感じたのだと思う。
TypeScriptからJavaScriptで書かれたライブラリを利用できる。利用する際には型定義ファイルが必要。型定義ファイルはCのヘッダファイルみたいなもの。TypeScriptからどのような型に見せたいかを記載しておく。トランスコンパイル時には、JavaScriptのライブラリの呼び出しに変わる。
サーバサイドでnodejsと組み合わせ使う場合、事前にトランスコンパイルしてnodeで実行すればよい。ts-nodeを使えば、tsのコードをそのまま実行できる。

感想

クライアントサイドの開発が大規模化したことへの対応が主目的と考えるのが良さそう。クライアントにはJS以外に現実的な選択肢がないため。一方、サーバサイドに関しては、JS以外にも様々な言語の選択肢があるため、JSに限界を感じるのであればTSではなく、他の言語を選択すればよい。

GraphQL

超概要

GraphQL自体は、データの問い合わせ言語。SQL的なもの。通信レイヤにHTTPSを使うことで、RESTの代わりにクライアントからのデータ問い合わせインタフェースとして使うことを想定されている。

RESTとの比較

RESTは、HTTPメソッドとURLを組み合わせて、どのデータにどんな操作を行うかを指定していた。GraphQLでは、それらはGraphQLのクエリとして表現する。RESTはサーバサイドの設計がシンプルになる一方で、それを呼び出すクライアント側の実装が複雑(というか面倒)になりがちだった。具体的には、複合的なデータの場合、欲しいデータを取得するまで、複数のREST APIの呼び出しの実行が必要だった。GraphQLでは、クエリの表現力が高いため、このようなケースでも1回のAPI呼び出しで済ますことができる。

サーバサイドの実装

サーバサイドの設計、実装は、RESTに比べると複雑になる。
それを補助するのがGraphQLサーバのApolloや、GraphQLを意識したORMappingツールであるPrisma。Apolloの主な役割は、GraphQLのクエリを受け付けて、パースし、クエリを処理するモジュール(Resolver)までつなげること。Prismaの主な役割は、Resolver内で各種DBへアクセスする処理を実現すること。Prismaにデータモデルの定義を渡すと、DBのテーブル作成と、DBにアクセスするためのクライアントコードが生成される。このクライアントを使って、Resolver内の処理を実装する形になる。

感想

TypeScriptの登場と同じく、GraphQLも、アプリケーションにおけるクライアントサイドの比重が増加してきたことに伴い、クライアントがの開発効率を上げるという視点でのアーキテクチャの改善提案と言えると思った。サーバ視点で見ると、Apollo Server + Prismaは、node + expressよりはわかりづらいし使いづらい。ネット上の情報量も少ない。
AWS AppSyncのようなマネージドサービスを使うことでどれくらい効率が上がるのか、次は調べてみたい。

次にやってみたいこと

  • AppSyncを触ってみる

Terraform vs Serverless Framework

超概要

TerraformではAWSのCloudFormationの代わりに、HashiCorpのHCLで構成を記述。terraformコマンドがHCLを読み込み、対応するAWS APIをコールして、環境を構築する。AWSの多くのリソースのデプロイに対応している。
Serverless Frameworkでも独自のYAML記法で構成を記述。slsコマンドがCloudFormationファイルを作成し、AWS CloudFormation APIをコールして環境が構築する。Lambdaのデプロイがメイン。それ以外のリソースはCloudFormation形式で書いてデプロイする。
上記の動作の違いは、両方のツールで同じLambdaのデプロイをして見るとよくわかる。Slsだと実行後にCloudFormationのStackができているが、TerraformではStackはない。

感想

AWSでの利用を前提とした時、TerraformはCloudFormationの代替として使うことが想定されていると思う。一方、Serverless FrameworkはCloudFormationと組み合わせて使うように設計されていると思う。
AWS謹製のCloudFormationを使わずに、これらのツールを使うメリットはベンダーロックイン度を低く抑えることと開発効率向上。前者はベンダーロックインの代わりにツールにロックインすることになるというデメリットも伴う。
AWSからの乗り換えを考えないのであれば、Servless Framework + CloudFormationの方がバランスがいいのかなと思った。もしくはツールは一切使わずに、AWSが提供する環境のみで行くというのもあり。最近はSAM Localなど、ローカルでの開発環境も充実してきたので。

CodeIgniter

超概要

基本機能

シンプルで分かりやすいMVCアーキテクチャのフレームワーク。Controller, Model, View。
URLからControllerへ処理がルーティングされる。デフォルトでは、/controller/method/args。カスタムしたい場合は、routes.phpに書く。
Model部分はQuery BuilderなるSQLクエリの作成支援ライブラリがある。クエリを直接書かなくてもクエリ発行が可能。ActiveRecordのようなORMappingはない。
あといろいろなHelperクラス。例えば、CSRF防止やValidationなど。

REST API

3rdライブラリのREST Serverを使うと楽。CI4では標準でREST実装で使うためのResourceControllerがある。

感想

先にCI4を使ってみたが、記法含め、馴染みやすかった。CIのMVCの考え方、PHP自体のオブジェクト指向な実装も、昔JavaでStructsを使っていた身にはすんなり入ってきた。そう意味ではレガシーなフレームワークと言えるかもしれない。
いまどきはUIはフロンとエンドフレームワークの役割になっているので、サーバサイドフレームワークとしては、UI機能は見る必要がない。API実装のやりやすさ、データベースアクセスのしやすさ、バッチ処理の対応などか。そういう観点では、現役でまだまだ使えるという感触。

Vue

超概要

HTMLとJavaScriptとCSSをセットでComponentとしてまとめて設計、実装して使いまわすことができる。
Vue CLIを使うとコマンド一発で、ローカルでの動作確認や、サーバにデプロイするためのWebPackによるパッケージング処理などを行ってくれる。
Netlifyなどの静的コンテンツのホスティングサービスを使うと、簡単にインターネットにWebアプリ公開もできる。

感想

Vue本体が提供する機能はシンプルなのですぐに慣れて、簡単なアプリなら作れるようになる。学習コストが低いというのは内製する場合も外注する場合でも開発リソースをスケールしやすいという意味でメリットが高い。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Elastic BeanstalkでサクッとWiFiマップアプリをデプロイする(Vue/Leaflet)

この記事はリンク情報システム2021新春アドベントカレンダー Tech Connect!のインデックス記事です。
Tech Connect! は勝手に始めるアドベントカレンダーとして、engineer.hanzomon のグループメンバによってリレーされます。
アドベントカレンダー2日目です。好きにアプリを作ってAWSにデプロイしてみました。

1.アプリケーションを作る

作成したアプリは下記のような感じ。データの元ネタは東京都オープンデータの公衆無線LANアクセスポイント

  • Vue:2.5.2
  • Vuetify:2.3.19
  • vuex:3.0.1
  • leaflet:1.7.1

Leaflet含めたMapの実装は下記となります。地図が動いて緯度経度情報が変わったら、storeに情報をセットします。mount時に現在値情報を取得し、現在地情報があれば地図描画時に付近を表示するように実装しました。

マーカーアイコンはLeaflet.awesome-markers、アイコンはFont Awesomeです。
Leaflet.awesome-markersはLeaflet後に宣言する必要があります。(「Uncaught ReferenceError: L is not defined」になる)

Map.vue
<template>
  <v-container id="mapid" class="com-map">
  </v-container>
</template>

<script>
import "leaflet/dist/leaflet.css";
import L from "leaflet";
import "../../static/js/leaflet.awesome-markers.js";
export default {
  name: "map",
  data() {
    return {
      map: null,
      twnCd: 130001
    };
  },
  mounted() {
    // Wifi情報と現在地情報を取得する
    this.getWifiList();
    this.getLocation();
  },
  computed: {
    /** Wifi情報 */
    wifiList: {
      get() {
        return this.$store.state.Map.wifiList;
      }
    },
    /** 地図表示範囲 */
    bounds: {
      get() {
        return this.$store.state.Map.bounds;
      }
    },
    /** 現在地 */
    crtLocation: {
      get() {
        return this.$store.state.Map.crtLocation;
      }
    },
    /** 現在地ズームフラグ */
    crtZmFlg: {
      get() {
        return this.$store.state.Map.crtZmFlg;
      }
    },
    /** ズーム情報 */
    zmInfo: {
      get() {
        return this.$store.state.Map.zmInfo;
      }
    }
  },
  methods: {
    /** WiFi情報を取得する */
    getWifiList() {
      setTimeout(() => {
        this.$store.dispatch("Map/getWifiList", {
          twnCd: this.twnCd
        });
      }, 500);
    },
    /** Map情報を設定する */
    setMapConfig() {
      let zmLat = 35.6825;
      let zmLon = 139.752778;
      if (this.crtLocation != null) {
        // 現在地が取得できた場合は現在地を設定する
        zmLat = this.crtLocation.latitude;
        zmLon = this.crtLocation.longitude;
      }

      this.map = L.map("mapid").setView([zmLat, zmLon], 13);
      // map情報を取得する
      const map = this.map;
      // mapの表示緯度経度を設定する
      this.setBounds();

      L.tileLayer("http://{s}.tile.osm.org/{z}/{x}/{y}.png", {
        maxZoom: 18
      }).addTo(map);

      // ダブルクリック時のズームをOFFにする
      map.doubleClickZoom.disable();
      // マップ動作時にmap表示範囲を設定する
      map.on("move", this.setBounds);

      // アイコンの設定をする
      var wifiMarker = L.AwesomeMarkers.icon({
        icon: "fa-wifi",
        markerColor: "darkblue",
        prefix: "fa"
      });

      for (var wifi of this.wifiList) {
        if (wifi.lat !== undefined && wifi.lon !== undefined) {
          let mark = L.marker([wifi.lat, wifi.lon], { icon: wifiMarker }).addTo(
            map
          );
          mark.on("click", function(e) {
            map.setView(e.latlng, 16);
          });
          mark.bindPopup(wifi.equipName);
        }
      }
    },
    /** 表示範囲情報を設定する */
    setBounds() {
      let bounds = this.map.getBounds();
      this.$store.commit("Map/setBounds", bounds);
    },
    /** 現在地を取得する */
    getLocation() {
      if (!navigator.geolocation) {
        return;
      }
      const options = {
        enableHighAccuracy: false,
        timeout: 5000,
        maximumAge: 0
      };
      navigator.geolocation.getCurrentPosition(
        this.success,
        this.error,
        options
      );
    },
    success(position) {
      // 成功した場合は現在の表示情報を取得する
      this.$store.commit("Map/setCrtLocation", position.coords);
    },
    error(error) {
      console.warn(`ERROR(${error.code}): ${error.message}`);
    }
  },
  watch: {
    /** Wifi情報を監視 */
    wifiList() {
      this.setMapConfig();
    },
    /** 現在地ズームフラグを監視 */
    crtZmFlg() {
      if (this.crtZmFlg && this.crtLocation != null) {
        // ズームフラグON、かつ現在地情報が取得できた場合
        let zmLat = this.crtLocation.latitude;
        let zmLon = this.crtLocation.longitude;
        const map = this.map;
        // 現在地にズームする
        map.flyTo([zmLat, zmLon], 15);
      }
      // エラー処理

      // 現在地ズームフラグOFF
      this.$store.commit("Map/setCrtZmFlg", false);
    },
    /** ズーム情報を監視 */
    zmInfo() {
      const map = this.map;
      // 公衆Wifiリスト押下時にズームする
      map.flyTo([this.zmInfo.lat, this.zmInfo.lon], 18);
    }
  }
};
</script>

横のWifiリスト一覧は下記のような感じ。地図の表示範囲情報を監視し、緯度経度情報が変わったら、actionを呼んで表示範囲内のWifiリストを生成します。

Panel.vue
<template>
  <v-card class="mx-auto blue-grey darken-4" height="100%">
    <v-spacer></v-spacer>
    <v-btn
      class="mt-4 ml-4"
      dark
      depressed
      outlined
      justify="center"
      @click="setCrtZmFlg"
    >
      <v-icon dark large class="mr-2 fas fa-home home_icon"> </v-icon>
      <span>現在地に移動</span>
    </v-btn>

    <v-list>
      <v-list-group
        v-for="item in dispRangeWifiList"
        :key="item.no"
        v-model="item.active"
        :prepend-icon="item.action"
        no-action
        @click="setZmInfo(item.lat, item.lon)"
      >
        <template v-slot:activator>
          <v-list-item-icon>
            <v-icon class="fas fa-wifi"></v-icon>
          </v-list-item-icon>
          <v-list-item-content>
            <v-list-item-title v-text="item.equipName"></v-list-item-title>
          </v-list-item-content>
        </template>

        <v-list-item>
          <v-list-item-content>
            <div>住所:{{ item.address }}</div>
            <div v-if="item.phoneNum">電話番号: {{ item.phoneNum }}</div>
            <div v-else>電話番号: -</div>
            <div>設置者:{{ item.installer }}</div>
            <div>最終確認日:{{ item.updateDate }}</div>
          </v-list-item-content>
        </v-list-item>
      </v-list-group>
    </v-list>
  </v-card>
</template>

<script>
export default {
  name: "panel",
  data() {
    return {
      clickFlg: true
    };
  },
  mounted() {},
  computed: {
    /** Wifi情報 */
    wifiList: {
      get() {
        return this.$store.state.Map.wifiList;
      }
    },
    /** 地図表示範囲 */
    bounds: {
      get() {
        return this.$store.state.Map.bounds;
      }
    },
    /** Wifi情報 */
    dispRangeWifiList: {
      get() {
        return this.$store.state.Map.dispRangeWifiList;
      }
    },
    /** 現在地 */
    crtLocation: {
      get() {
        return this.$store.state.Map.crtLocation;
      }
    },
    /** 現在地ズームフラグ */
    crtZmFlg: {
      get() {
        return this.$store.state.Map.crtZmFlg;
      }
    },
    /** ズーム情報 */
    zmInfo: {
      get() {
        return this.$store.state.Map.zmInfo;
      }
    }
  },
  methods: {
    /** 表示範囲内のWifiの情報を取得する */
    getDispRangeWifiList() {
      setTimeout(() => {
        this.$store.dispatch("Map/getDispRangeWifiList", {
          wifiList: this.wifiList,
          bounds: this.bounds
        });
      }, 500);
    },
    /** 現在地ズームフラグON */
    setCrtZmFlg() {
      this.$store.commit("Map/setCrtZmFlg", true);
    },
    /** Wifi一覧押下時のズーム */
    setZmInfo(lat, lon) {
      if (this.clickFlg) {
        let info = {
          lat: lat,
          lon: lon
        };
        this.$store.commit("Map/setZmInfo", info);
        // リストを閉じる時はズームは動作しない
        this.clickFlg = false;
      } else {
        this.clickFlg = true;
      }
    }
  },
  watch: {
    /** 地図表示範囲を監視 */
    bounds() {
      if (this.wifiList.length) {
        this.getDispRangeWifiList();
      }
    }
  }
};
</script>

2.AWS Elastic Beanstalkデプロイ用にアプリを構成する

AWS Elastic BeanstalkでVue(静的コンテンツ)をデプロイする場合は下記のような構成になります。Nginx側に静的コンテンツを配置するのではなく、HTTPサーバとしてExpressを立てて動くイメージです。
(ここを誤解していて随分時間を無駄にしました。。)
名称未設定ファイル.png

Vueとは別にExpressのプロジェクトを作成し、index.htmlにルーティングするように設定します。
AWS Elastic Beanstalkにデプロイするにあたっては2つ注意点があります。

  • package.jsonに「start」コマンドを定義すること
  • AWS Elastic Beanstalkではポート8080で動くので、expressの起動を3000ポートで固定しないこと

package.jsonは下記のような感じです。

{
  "name": "proxy",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "del": "^6.0.0",
    "express": "^4.17.1",
    "fancy-log": "^1.3.3",
    "gulp": "^4.0.2",
    "gulp-zip": "^5.0.2",
    "nodemon": "^2.0.4",
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.11",
    "webpack-stream": "^5.2.1"
  }
}

ルーティング設定は下記の様にしました。
静的コンテンツがある場合(static)、定義を入れる必要があります。(入れないとGET http://~.js net::ERR_ABORTED 404 (Not Found)になり、画面が真っ白になる。)

app.js
const express = require("express");
const app = express(),
  port = process.env.PORT || 3000;

// 静的コンテンツを定義
app.use("/static", express.static(__dirname + "/wifi-map-app/dist/static"));
// ルートアクセスはindex.htmlを参照するようにする
app.get("/", (req, res) => {
  res.sendFile(process.cwd() + "/wifi-map-app/dist/index.html");
});

app.listen(port, () => {
  console.log(`Server listening on the port::${port}`);
});

最終的にはExpressとVue合わせてzip圧縮した資材をAWSにアップロードします。自動で圧縮ファイルを構築できるようにgulpを利用してビルド定義を書きます。配置先はpackage.jsonと同ディレクトリです。
この辺の話しは下記サイトが詳しいので、ご参照ください。
https://medium.com/bb-tutorials-and-thoughts/aws-deploying-vue-js-with-nodejs-backend-on-elastic-beanstalk-e055314445c5

gulpfile.js
const { src, dest, series, parallel } = require("gulp");
const del = require("del");
const fs = require("fs");
const zip = require("gulp-zip");
const log = require("fancy-log");
var exec = require("child_process").exec;

const paths = {
  prod_build: "../prod-build",
  server_file_name: "./app.js",
  server_package_json: "package.json",
  server_module: "node_modules/**/*",
  server_module_dist: "../prod-build/node_modules",
  vue_src: "../wifi-map-app/dist/**/*",
  vue_dist: "../prod-build/wifi-map-app/dist",
  zipped_file_name: "vuejs-nodejs.zip",
};

function clean() {
  log("removing the old files in the directory");
  return del("../prod-build/**", { force: true });
}

function createProdBuildFolder() {
  const dir = paths.prod_build;
  log(`Creating the folder if not exist  ${dir}`);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir);
    log("folder created:", dir);
  }

  return Promise.resolve("the value is ignored");
}

function buildVueCodeTask(cb) {
  log("building Vue code into the directory");
  return exec("cd ../wifi-map-app && yarn build", function (
    err,
    stdout,
    stderr
  ) {
    log(stdout);
    log(stderr);
    cb(err);
  });
}

function copyVueCodeTask() {
  log("copying Vue code into the directory");
  return src(`${paths.vue_src}`).pipe(dest(`${paths.vue_dist}`));
}

function copyNodeJSCodeTask() {
  log("building and copying server code into the directory");
  return src([
    `${paths.server_file_name}`,
    `${paths.server_package_json}`,
  ]).pipe(dest(`${paths.prod_build}`));
}

function copyNodeJSModules() {
  log("copying nodejs modules into the directory");
  return src(`${paths.server_module}`).pipe(dest(`${paths.server_module_dist}`));
}

function zippingTask() {
  log("zipping the code ");
  return src(`${paths.prod_build}/**`)
    .pipe(zip(`${paths.zipped_file_name}`))
    .pipe(dest(`${paths.prod_build}`));
}

exports.default = series(
  clean,
  createProdBuildFolder,
  buildVueCodeTask,
  parallel(copyVueCodeTask, copyNodeJSCodeTask, copyNodeJSModules),
  zippingTask
);

Expressプロジェクト配下で「glup」コマンドを実行して、ビルドします。
下記のようにVueのビルド資材とexpressが配置できたらOKです。vuejs-node.zipがAWS Elastic Beanstalkでアップロードする資材となります。
image.png

3.AWS Elastic Beanstalkでインフラ環境をつくる

AWS Elastic Beanstalkを利用すれば下記が自動で生成されます。
※詳しいチュートリアルはこちらから

・Amazon Elastic Compute Cloud (Amazon EC2) インスタンス (仮想マシン)
・Amazon EC2 セキュリティグループ
・Amazon Simple Storage Service (Amazon S3) バケット
・Amazon CloudWatch アラーム
・AWS CloudFormation スタック
・ドメイン名

Elastic Beanstalkの画面を開いて、ウェブアプリケーションを作成します。
image.png

プラットフォームはNode.jsを選択します。デプロイするアプリケーションは、上記で作成したvuejs-node.zipでも良いですが、一旦サンプルアプリケーションで動作確認をするのをお勧めします。
「環境の作成」を押下し、完了するまでしばし待ちます。
image.png

無事に作成が完了すると下記のような画面になります。
image.png

EC2に行ってみるとインスタンスも追加されています。
image.png

 4.アプリをデプロイする

アプリケーションバージョン>アップロードで、glupでビルドした資材を選択して、アップロードを押下します。
image.png

アップロードした資材を選択して「デプロイ」を押下すると、環境の変更が開始されます。
アプリは履歴になっているので前のバージョンに戻ってデプロイすることも可能です。(便利!)
image.png

http://作成されたドメイン/でアクセスすると、アプリがデプロイできていることを確認できます。
image.png

AWS Elastic Beanstalkを利用したインフラ構築+デプロイは以上です。

あとがき

フロント側の仕事が増えたので、今回画面も作ってみました。AWS Elastic Beanstalkを利用すればインフラ気にしないでアプリのデプロイが可能なので手っ取り早いです。オートスケールとかLB、ドメインも自動設定されるので便利だと思いました。IP制限とかすれば、開発用の環境としても使い勝手良さそうです。

3日目は@usankusaiさんです!引き続きよろしくお願いします!


リンク情報システム株式会社では一緒に働く仲間を随時募集しています!
また、お仕事のご依頼、ビジネスパートナー様も募集しております。お気軽にご連絡ください。
Facebookはこちら

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Vue.js] data の扱い方

Vue.js での data の扱い方について個人的な見解をまとめてみます。
当たり前の内容か、逆に間違ってると思われるかもしれません。

今回以下のように、リストを編集するためのモーダルを開き、チェックを入れてアイテムを削除するサンプルプログラムを例に考えてみます。
test.gif

関連のソースを以下に用意してみました。
vue-ramda-ts-playground

説明したい内容に伴い、コンポーネントの構成は以下のようになってます。

  • ルートのページ - views/TheView/Component.vue
  • モーダル - components/TheModal/Component.vue
  • モーダルのリスト部分 - components/TheModal/Items/Component.vue

data を書き換える責務について

基本的に、 data として定義したプロパティを変更できるのは、その data を定義したコンポーネントのみになると思っています。
今回、リスト編集モーダルを モーダルモーダルのリスト部分 で分割し、編集用のリストデータの定義は モーダル のコンポーネントで行うようにしました。
その場合、チェックボックスのクリックにより、選択の状態を変更できるのは、 モーダル のみになります( リスト部分 で変更するのは多分良くない)。

具体的には、以下のような書き方はあまり良くないのではないかなと思います。

モーダル.vue
<template>
  <div class="the-modal">
    <the-modal-items
      :items="editingItems"
    />
  </div>
</template>

<script>
import TheModalItems from '@/components/TheModal/Items/Component.vue'

export default {
  components: {
    TheModalItems
  },
  data () {
    return {
      // リスト
      editingItems: [
        {
          id: 0,
          name: 'Item 0',
          isChecked: false
        },
        {
          id: 1,
          name: 'Item 1',
          isChecked: false
        },
        // ...省略
        {
          id: 18,
          name: 'Item 18',
          isChecked: false
        },
        {
          id: 19,
          name: 'Item 19',
          isChecked: false
        }
      ]
    }
  }
}
モーダルのリスト部分.vue
<template>
  <ul class="the-modal-items">
    <li
      v-for="item in items"
      :key="item.id"
    >
      <input
        v-model="item.isChecked"
        type="checkbox"
      />
    </li>
  </ul>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true
    }
  }
}
</script>

リスト部分コンポーネントの、 v-model="item.isChecked" のところが良くないということになります。
ここで直接 prop されたデータを書き換えてるのですが、以下のような問題を生んでしまいます。

  • よりコンポーネンの階層が深くなった場合、どこでデータを書き換えてるのか辿るのが大変になってしまう。
  • データ書き換えの前後で親側で任意の処理を挟みたくなった場合に大規模な修正が必要になってしまう。

など...。
なるべく今回のようなコンポーネントは

  • prop に基づいた描画
  • $emit を利用したイベント通知

のみに責務を留めておくべきだと思います。
これについてはこちらの Avoid Mutating a Prop Directly エラーの記事 なんかも参考になります。

In Vue, we pass data down the the component tree using props. A parent component will use props to pass data down to it's children components. Those components in turn pass data down another layer, and so on.
Then, to pass data back up the component tree, we use events.
We do this because it ensures that each component is isolated from each other. From this we can guarantee a few things that help us in thinking about our components:
Only the component can change it's own state
Only the parent of the component can change the props

(ざっくり、「各コンポーネントの分離を保証するために、データの書き換えにはイベント通知を利用するということ。コンポーネントは自身の状態のみ変更できる、親コンポーネントのみが props を変更できる。」 のようなことがおそらく言われてます。)

従ってリスト部分のコンポーネントを修正します。

モーダルのリスト部分.vue
<input
  :checked="item.isChecked"
  @input="$emit('on-check', { shouldBeChecked: $event.target.checked, targetItem: item })"
  type="checkbox"
/>

v-model の代わりに :checked@input="$emit" を使うようにしてみました( v-model を get/set でバインドするのでも可)。

親側でイベントを受け取ってデータを書き換えます。

モーダル.vue
<template>
  <div class="the-modal">
    <the-modal-items
      :items="editingItems"
      @on-check="checkItem"
    />
  </div>
</template>

<script>
// ...省略

export default {
  // ...省略
  methods: {
    // ※ チェックするメソッドを追加
    checkItem ({ shouldBeChecked, targetItem }) {
      this.editingItems.forEach((item) => {
        if (item === targetItem) {
          item.isChecked = shouldBeChecked
        }
      })
    }
  }
}
</script>

これで大丈夫だと思います。

ただ、個人的な好みにすぎませんが、リアクティブなプロパティに対して非破壊的に操作した結果を、定義したプロパティに再代入してあげる書き方の方が好きです。
うまく言い表せなかったのですが、具体的には以下のような書き方になります。

モーダル.vue
<script>
export default {
  methods: {
    checkItem ({ shouldBeChecked, targetItem }) {
      this.editingItems = this.editingItems.map((item) => {
        if (item === targetItem) {
          return Object.assign({}, item, { isChecked: shouldBeChecked })
        }
        return item
      })
    }
  }
}
</script>

この書き方の場合、子側で親側のデータを書き換えようとするとエラーになってくれます。

モーダルのリスト部分.vue
<template>
  <ul class="the-modal-items">
    <li
      v-for="item in items"
      :key="item.id"
    >
     <input
        :checked="item.isChecked"
        @input="checkItem({ shouldBeChecked: $event.target.checked, targetItem: item })"
        type="checkbox"
      />
    </li>
  </ul>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true
    }
  },
  methods: {
    checkItem ({ shouldBeChecked, targetItem }) {
     /**
      * [Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders.
      * Instead, use a data or computed property based on the prop's value.
      */
      this.items = this.items.map((item) => {
        if (item === targetItem) {
          return Object.assign({}, item, { isChecked: shouldBeChecked })
        }
        return item
      })
    }
  }
}
</script>

※ data に定義したプロパティに再代入するまでの仮定は非破壊的でないと意味がありません。
ここで破壊的な操作を許してしまうと結局子コンポーネントでもデータを書き換えれてしまうため...。

いずれにしろ、先ほどの Avoid Mutating a Prop Directly エラーの記事 で、

各コンポーネントの分離を保証するために

という言い方をしてみましたが、
「チェックのイベントが通知された時に何をするかはコンポーネント利用側が自由に決めれる」 = 「各コンポーネントが分離されている」
ということなのだと思います。

リアクティブなプロパティを動的に生成する

先ほど、 非破壊的 という言葉を使ってみたり、 return Object.assign({}, item, { isChecked: shouldBeChecked }) のような記述をしてみましたが、この辺の意識は他にも役立つ場面があると思います。
リアクティブなプロパティを動的に生成する場合を例に考えてみます。

今回、ルートのページにリストのデータを用意してあげて、モーダルを開いた際にそのデータを prop でモーダルに注入してあげたいと思います。
下記のようなイメージです。

ルートページ.vue
<template>
  <div class="the-view">
    <!-- モーダルを開くためのボタン ↓ -->
    <button
      @click="isModalVisible = true"
    >
      Edit
    </button>
    <!-- リスト描画 ↓ -->
    <ul>
      <li
        v-for="item in items"
        :key="item.id"
      >
        {{ item.name }}
      </li>
    </ul>
    <!-- モーダルコンポーネント ↓ -->
    <the-modal
      v-if="isModalVisible"
      :items="items"
    />
  </div>
</template>

<script>
import TheModal from '@/components/TheModal/Component.vue'

export default {
  components: {
    TheModal
  },
  data () {
    return {
      // リスト
      items: [
        {
          id: 0,
          name: 'Item 0'
        },
        {
          id: 1,
          name: 'Item 1'
        },
        // ...省略
        {
          id: 18,
          name: 'Item 18'
        },
        {
          id: 19,
          name: 'Item 19'
        }
      ]
      isModalVisible: false // モーダル表示フラグ
    }
  }
}
</script>
モーダル.vue
<template>
  <div class="the-modal">
    <the-modal-items
      :items="editingItems"
    />
  </div>
</template>

<script>
import TheModalItems from '@/components/TheModal/Items/Component.vue'

export default {
  components: {
    TheModalItems
  },
  props: {
    items: {
      type: Array,
      required: true
    }
  },
  data () {
    return {
      editingItems: [] // このモーダルで扱う、編集用のリストデータ
    }
  },
  created () {
    // ※ ここで、注入された items を元に editingItems を初期化したい。
  },
  // ...省略
}
</script>

モーダルでは、 created フックで、注入された各アイテムに isChecked: boolean のプロパティを付与したリストを持たせることで、アイテムの選択を実現できるようにしたいです(チェックボックスの状態管理の方法は他にもありますが、今回はあえてこのようにしてみます)。

JS も Vue もはじめてやるという場合、もしかしたら以下のように書いてしまうかもしれません。

モーダル.vue
<script>
export default {
  created () {
    this.editingItems = this.items.map((item) => {
      item.isChecked = false
      return item
    })
  }
}
</script>

しかし、これではチェックボックスがリアクティブに動作しないのと、その他の問題も生んでしまいます。
この件については、 JS がプリミティブ型意外全て参照渡しになるという前提のもと、以下のことが言えます。

  1. 既にリアクティブであるオブジェクトに対して動的にプロパティを付与しても、付与されたプロパティに関してリアクティブに動作してくれない。
  2. 親コンポーネントの items データにも isChecked が付与されてしまう。
  3. 仮に親コンポーネントの itemsisChecked を付与したかったとしても、その責務を子コンポーネントが負ってしまっている。

従って、 公式 を参考に、以下のようにしてみます。

モーダル.vue
<script>
export default {
  created () {
    this.editingItems = this.items.map((item) => {
      return Object.assign({}, item, { isChecked: false })
    })
  }
}
</script>

Object.assign({}, item, { isChecked: false })
こちらは、第一引数に用意した新しい参照に対して、第二引数、第三引数にとっては非破壊的にマージした結果を返してくれます。
これで上記全ての問題が解決します。

リアクティブなプロパティを動的に生成する上でのポイントは、第一引数のリアクティブでない新しい参照を用意するところにあります。
ただ、今回のように既にリアクティブであるプロパティを元にして生成する場合、これに対して非破壊的に操作するということを意識すれば、結果的にリアクティブでない新しい参照を用意することになります。
最終的な再代入が破壊的ですが、先ほどと同じで定義したプロパティそのものに再代入するまでの仮定で非破壊を意識する格好になります。

関数型

非破壊的なオブジェクト操作に、関数型は役立つと思います。
今回はカリー化に強みを持つ Ramda.js というライブラリを使ってモーダルのデータ更新処理を書き換えてみます。

モーダル.vue
<script>
import { assoc, equals, map, when } from 'ramda' // ramda 読み込み

export default {
  created () {
    this.editingItems = map(
      assoc('isChecked', false)
    )(this.items)
  },
  methods: {
    checkItem ({ shouldBeChecked, targetItem }) {
      this.editingItems = map(
        when(
          equals(targetItem),
          assoc('isChecked', shouldBeChecked)
        )
      )(this.editingItems)
    }
  }
}
</script>

どのような文脈のコードが好きかは好みが別れるところだと思います。
ただ、 関数型のライブラリは他にもネイティブのJSにはない便利な関数なんかも提供してくれてたりします。
ちなみに今回利用した assoc なんかは

node_modules/ramda/src/assoc.js
var assoc =
/*#__PURE__*/
_curry3(function assoc(prop, val, obj) {
  var result = {};

  for (var p in obj) {
    result[p] = obj[p];
  }

  result[prop] = val;
  return result;
});

module.exports = assoc;

var result = {}; のところで分かるように新しい参照を用意してくれてます。
関数型のその他のメリットについてはまた別件ですが、とりあえずこの辺が抽象化されているのは Vue 的には嬉しいと思います。

多くの共有状態のシビアな管理が求められる Vue での開発において、副作用のないメソッドは安全に開発をする上での強い味方になるんじゃないかなと思っています。

最後に

読んでくださりありがとうございました。
今後台頭してくる Composition Api を利用したモジュール単位での状態管理においては、分割した各モジュールを連携させる際の都合上、どのコンテキストにデータを書き換える責務があるのかという視点はまた変わってきたり、チームごとに新たな規約が考案されていくんじゃないかなと思っていて、楽しみです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む