Help us understand the problem. What is going on with this article?

[Vue.js]親子コンポーネント・チュートリアル(共通コンポーネント作成と親子間データ連携)

More than 1 year has passed since last update.

前提

Vue の環境がインストール済み、という前提で進めます。

Vue 3.11.0

Vue CLI のプロジェクトを作成した直後の状態からスタートしています。

作るもの

会員情報や顧客情報などで必要になるのが住所を入力する部分ですよね。複数画面で出てきそうだし、出来ればコンポーネントとして切り出して使いまわしたいもの…。

と言う訳で、郵便番号と住所の入力コンポーネントを作成してみます。

2019-09-19_22h32_49.png

(この記事で紹介する方法がベストかどうか怪しいところ…。ご指摘、アドバイスなどありましたら、お手柔らかにコメント頂けるとありがたいです。)

親画面を作成

親画面となる「アカウント設定」ページを用意しておきます。

\src\MyPage.vue を新規作成します。

src\MyPage.vue
<template>
  <div>
    <div>
      <span>氏名:</span>
      <span>
        <input type="text" v-model="userName" />
      </span>
    </div>
    <div>
      <span>住所:</span>
      <span>【ここに住所コンポーネント】</span>
    </div>
  </div>
</template>

<script>
export default {
  name: "MyPage",
  data: function() {
    return {
      userName: "田中さん"
    };
  }
};
</script>

routing は今回特に設定していないので、http://localhos:8080/ のルートにアクセスした際に、すぐこの MyPage.vue が表示されるようにしてしまいます。

App.vue が HelloWorld.vue を呼び出すようになっているので、その部分を修正します。

src\App.vue
<template>
  <div id="app">
    <my-page />
  </div>
</template>

<script>
import MyPage from './MyPage.vue'

export default {
  name: 'app',
  components: {
    MyPage
  }
}
</script>

<style>

</style>
  • 3行目のタグ
  • 8行目のimport
  • 13行目の components 名称

を変更しています。

さて、この状態でブラウザから見ると、こんな感じで表示されました。

2019-09-17_21h22_55.png

子コンポーネントを用意する

次に住所コンポーネントを用意します。

src\components\ComponentAddress.vue
<template>
  <div>
    <div>
      <span></span>
      <input type="text" v-model="zipcode" />
    </div>
    <div>
      <span>都道府県</span>
      <span>
        <input type="text" v-model="address1" />
      </span>
    </div>
    <div>
      <span>市町村名</span>
      <span>
        <input type="text" v-model="address2" />
      </span>
    </div>
    <div>
      <span>町域名</span>
      <span>
        <input type="text" v-model="address3" />
      </span>
    </div>
  </div>
</template>
<script>
export default {
  name: "ComponentAddress",
  data: function() {
    return {
      zipcode: "",
      address1: "",
      address2: "",
      address3: ""
    };
  }
};
</script>

データの連携は後回し。まずは、画面上に必要な項目が表示される状態にします。

郵便番号検索は zipcloud を利用させていただく予定なので、こちらのフィールド名に合わせて作ってあります。

作成した住所コンポーネントを、 MyPage.vue に組み込みます。

src\MyPage.vue
<template>
  <div>
    <div>
      <span>氏名:</span>
      <span>
        <input type="text" v-model="userName" />
      </span>
    </div>
    <div>
      <span>住所:</span>
      <span><component-address /></span>
    </div>
  </div>
</template>

<script>
import ComponentAddress from "./components/ComponentAddress";
export default {
  name: "MyPage",
  components: { ComponentAddress },
  data: function() {
    return {
      userName: "田中さん"
    };
  }
};
</script>
  • 11行目:挿入するコンポーネント用のタグを指定します。
  • 17行目:import にて住所コンポーネントを読み込みます。
  • 20行目:Vue内で使用するコンポーネントの宣言に住所コンポーネントを指定します。

これで、MyPage に住所コンポーネントが表示されるようになりました。(見た目ガタガタですけどね…)
2019-09-18_10h23_58.png

親子コンポーネント間でデータ連携する

次に、親コンポーネントで保持している住所項目(多くの場合は、DBで保存される値)と、子コンポーネントでレンダリングされる値を連携させてやる必要があります。

この辺りの処理、特に複数項目をうまいコト連携させるには?っていう辺りで結構悩んで試行錯誤しました。

探し方が悪いのだとは思いますが、なかなか「お!これだっ!」と思えるような情報にもたどり着けなかった、というのが今回この記事を書くきっかけでもあります。

Vue では「Events Up, Props Down」の鉄則があるので、props で渡ってきた項目を直接さわれません。

かと言って、子コンポーネント側で this.$emit('updateMyComponent', value1, value2) のようなイベントを発火させて、親側でそのイベントをハンドリングして…

src\MyPage.vue
<template>
    (略)
    <my-component @updateMyComponent="updateParentValues">
    (略)
</template>
<script>
    
    methods: {
        updateParentValues( val1, val2 ) {
            // 親側の値を更新する
        }
    }
</script>

なんてことになると、親子間の結合度が上がってしまい、かなりイヤな感じですよね…。

<my-component> のタグ1行書いたら、あとは全部やっといてくれよ!って言いたくなります。

さて、そこで登場するのが v-model v-bind.sync 。

  • 【2019/09/22】 修正azechiさん から「この場合は v-bind.sync が良いのでは?」とのコメントを頂きましたので、 v-model にオブジェクトを渡すのではなく、 v-bind.sync を使うように変更しました。azechiさん、ありがとうございました!

親コンポーネントの準備

まずは 必要な情報を持つオブジェクトを準備。data に追加します。

src\MyPage.vue
<script>

  data: function() {
    return {
      userName: "田中さん",
      addr: {
        zipcode: "",
        address1: "",
        address2: "",
        address3: ""
      }
    };
  },

</script>

続いて、親コンポーネント内で子コンポーネントを呼び出している部分、

src\MyPage.vue
<component-address />

に v-bind.sync を追加して、このオブジェクトを渡してやります。

src\MyPage.vue
<component-address v-bind.sync="addr" />

ついでに、「子コンポーネントに入力した値が正しく反映されるのか?」を見るために、親コンポーネント内にオブジェクトの中身を表示しておきます。

src\MyPage.vue
<template>
(略)
    <div>
      <p>親コンポーネント</p>
      <p>addr.zipcode  : {{addr.zipcode}}</p>
      <p>addr.address1 : {{addr.address1}}</p>
      <p>addr.address2 : {{addr.address2}}</p>
      <p>addr.address3 : {{addr.address3}}</p>
    </div>
(略)
</template>

親コンポーネントはこんな感じになりました。

src\MyPage.vue
<template>
  <div>
    <div>
      <span>氏名:</span>
      <span>
        <input type="text" v-model="userName" />
      </span>
    </div>
    <div>
      <span>住所:</span>
      <span>
        <component-address v-bind.sync="addr" />
      </span>
    </div>
    <div>
      <p>親コンポーネント</p>
      <p>addr.zipcode : {{addr.zipcode}}</p>
      <p>addr.address1 : {{addr.address1}}</p>
      <p>addr.address2 : {{addr.address2}}</p>
      <p>addr.address3 : {{addr.address3}}</p>
    </div>
  </div>
</template>

<script>
import ComponentAddress from "./components/ComponentAddress";
export default {
  name: "MyPage",
  components: { ComponentAddress },
  data: function() {
    return {
      userName: "田中さん",
      addr: {
        zipcode: "",
        address1: "",
        address2: "",
        address3: ""
      }
    };
  }
};
</script>

子コンポーネントの準備

次は子コンポーネント側です。

まずは、親から渡されたオブジェクトのプロパティー名を持つ props を準備。これで親の v-bind.sync に渡されたオブジェクト(のプロパティーの値)を受け取れるようになります。

src\components\ComponentAddress.vue
  props: {
    zipcode: "",
    address1: "",
    address2: "",
    address3: ""
  }

次に、各入力フィールド の設定ですが、先に 「やっちゃダメ!」 な例をお伝えしておきます。

inputフィールドの内容と、Vueで保持している値を結びつけるために、 v-model を使うシーンは多いかと思います。

なので、子コンポーネント内の各 フィールドに、v-model を設定しまして…

src\components\ComponentAddress.vue
<template>
(略)
    <div>
      <span></span>
      <input type="text" v-model="zipcode" />
    </div>
(略)
</template>

とやると、ブラウザのデベロッパーツール(F12)コンソールにこんな Warning メッセージが出て上手くいきません。

[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. Prop being mutated: "value"

v-model は、以下と同じなので、

<input
  type="text"
  v-bind:value="zipcode"
  v-on:input="zipcode = $event.target.value"
>

v-on:input で zipcode を直接書き換えてしまってます。

補足

ちなみに、親コンポーネント側で

<component-address v-model="objAddr" />

としてオブジェクトを渡してやり、子コンポーネント側で

<template>
(略)
  <input type="text" v-model="objAddr.zipcode" />
(略)
</template>
<script>

  props: {
    objAddr: null
  }

</script>

とすると、いい感じで動くように見えます。(わざわざ method: に input イベントリスナーを用意しなくても、親側の値も更新される。)

が、「props で渡ってきた値、直接書き換えてませんよね?」の判定時に、オブジェクト内のプロパティーまでは見ておらず、 Warning が表示されなかっただけ。props を直接変更してはいけない、というVueのルールに反していますので、この方法はNGです。


話を戻しまして、子コンポーネント側の各入力フィールド を以下のように設定します。

<template>
(略)
  <input type="text" :value="zipcode" @input="$emit('update:zipcode', $event.target.value)/>
(略)
</template>

Vue.js 公式ドキュメント「カスタムイベント — .sync 修飾子」 に書かれているように、

<text-document v-bind.sync="doc"></text-document>
こうする事で doc オブジェクト内の各プロパティ (例えば title) がひとつのプロパティとして渡され、v-on アップデートリスナがそれぞれに付けられます。

つまり、 v-bind:sync でオブジェクトを渡した場合、親コンポーネント側で自動的に update:prop名 のイベントを監視するようになるわけです。

子コンポーネントのソースは以下のようになりました。(data は使わなくなったので削除してあります。)

src\components\ComponentAddress.vue
<template>
  <div>
    <div>
      <span></span>
      <input type="text" :value="zipcode" @input="$emit('update:zipcode', $event.target.value)" />
    </div>
    <div>
      <span>都道府県</span>
      <span>
        <input type="text" :value="address1" @input="$emit('update:address1', $event.target.value)" />
      </span>
    </div>
    <div>
      <span>市町村名</span>
      <span>
        <input type="text" :value="address2" @input="$emit('update:address2', $event.target.value)" />
      </span>
    </div>
    <div>
      <span>町域名</span>
      <span>
        <input type="text" :value="address3" @input="$emit('update:address3', $event.target.value)" />
      </span>
    </div>
  </div>
</template>
<script>
export default {
  name: "ComponentAddress",
  props: {
    zipcode: "",
    address1: "",
    address2: "",
    address3: ""
  }
};
</script>

というわけで、ここまでで、まずは親子コンポーネントの連携ができるようになりました。

(下の画像について補足:〒~町域名の入力欄は子コンポーネント。その下の「親コンポーネント」は、親側で保持している値を表示しています。実際の業務では、親画面側に「保存」のようなボタンを設置し、クリックされた際に親側で保持している値を使ってDBに書き込みに…といった処理になるかと思います。)
Vue01.gif

郵便番号から住所を取得する

次は郵便番号を入力したら自動で住所を取得する部分を作ります。

郵便番号検索は zipcloud を利用させていただきます。

WebAPIなので axios を導入してデータを取得します。

npm install axios --save

で axios 導入をしまして、子コンポーネントの郵便番号入力欄が更新されたら(input イベントではなく change イベントにて)住所の問い合わせをするようにしてみます。

src\components\ComponentAddress.vue
<template>
(略)
    <div>
      <span></span>
      <input
        type="text"
        :value="zipcode"
        @input="$emit('update:zipcode', $event.target.value)"
        @change="updateZipcode($event.target.value)"
      />
    </div>

(略)
</template>
<script>
import axios from "axios";

</script>

そして、methods: にイベントハンドラとして指定した updateZipcode を追加・・・。

src\components\ComponentAddress.vue
<script>

    updateZipcode: function(zipcode) {
      axios
        .get("http://zipcloud.ibsnet.co.jp/api/search", {
          params: {
            zipcode: zipcode,
          }
        })
        .then(response => {
          // 取得した住所を反映させる処理
        })
        .catch(error => {
          // エラー処理
        });
    }
  }

</script>

というところまでは良かったのですが、ここに来て CORS 問題が発生。異なるドメイン間( localhost と zipcloud.ibsnet.co.jp )での通信なので、

Access to XMLHttpRequest at 'http://zipcloud.ibsnet.co.jp/api/search?zipcode=1000014' from origin 'http://localhost:8080' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

と怒られてしまいます。

と言う訳でJSONPでの通信に切り替えましょ。(調べて悩んだ挙句、「仕方がない、CORS Proxy 立てるか…」と Fiddler をインストールした直後に、「あ、JSONP にすればいいのか…」と気づいた。)

axios は jsonp に対応していないらしいので、axios-jsonp を導入します。

npm install axios-jsonp --save

zipcloud さんのドキュメントによると、 callback というパラメータを指定してやれば、JSONP のコールバック関数名がそれになる、とのこと。

一方、 axios-jsonp 側にもコールバック関数名を指定することができます。

別途 callbackZipCloud() なんていう関数を用意して・・なんてやってみましたが、そんな関数ありません、って怒られます。

正解は、

  • zipcloud のパラメータ: callback は 指定しない。デフォルトの callback になる。
  • axios-jsonp の callbackParamName: "callback" を指定する。

でした。

こうすることで、わざわざ別途 callback 関数を用意しなくても、 axios の then() に Promise オブジェクトとして渡ってきます。

最終的に、子コンポーネントはこんなソースになりました。

src\components\ComponentAddress.vue
<template>
  <div>
    <div>
      <span></span>
      <input
        type="text"
        :value="zipcode"
        @input="$emit('update:zipcode', $event.target.value)"
        @change="updateZipcode($event.target.value)"
      />
    </div>
    <div>
      <span>都道府県</span>
      <span>
        <input type="text" :value="address1" @input="$emit('update:address1', $event.target.value)" />
      </span>
    </div>
    <div>
      <span>市町村名</span>
      <span>
        <input type="text" :value="address2" @input="$emit('update:address2', $event.target.value)" />
      </span>
    </div>
    <div>
      <span>町域名</span>
      <span>
        <input type="text" :value="address3" @input="$emit('update:address3', $event.target.value)" />
      </span>
    </div>
  </div>
</template>
<script>
import axios from "axios";
import axiosJsonpAdapter from "axios-jsonp";
export default {
  name: "ComponentAddress",
  props: {
    zipcode: "",
    address1: "",
    address2: "",
    address3: ""
  },
  methods: {
    updateZipcode: function(zipcode) {
      axios
        .get("http://zipcloud.ibsnet.co.jp/api/search", {
          adapter: axiosJsonpAdapter,
          callbackParamName: "callback",
          params: {
            zipcode: zipcode,
            //callback: "this.callbackZipCloud",    // ←エラーになる
            limit: 10
          }
        })
        .then(response => {
          console.log(response);
          // 郵便番号に該当する住所データが返ってきたら画面上の住所を更新(上書き)する。
          if (
            response.data &&
            response.data.status == 200 &&
            0 < response.data.results.length
          ) {
            const res = response.data.results[0];
            // this.address1 = res.address1 のように子コンポーネント側を更新してしまうと Prop being mutated Warningが発生してしまう。
            // "update:プロパティー名"イベントを発火させてやれば、親の値が更新されて子コンポーネントにも反映される。
            this.$emit("update:address1", res.address1);
            this.$emit("update:address2", res.address2);
            this.$emit("update:address3", res.address3);
          }
        })
        .catch(error => {
          console.error(error);
        });
    }
  }
};
</script>

これで、郵便番号欄の change イベント発生時に住所を取得+画面に表示することができました。
Vue02.gif

★2019年10月~の案件探してます。

希望詳細もろもろこちら↓に掲載しています。

Twitter↓ DM開放してありますのでご興味ある方いらっしゃいましたらお声がけください。

goodengineer7
Tech系ニュースPodcast配信中📢 http://bit.ly/TechFreePodcast 平日はフリーエンジニア(Java/C#→Laravel+Vue.jsに転向)・週末は家族とド田舎暮らし
https://free-engineer.xrea.jp/
admin-guild
「Webサービスの運営に必要なあらゆる知見」を共有できる場として作られた、運営者のためのコミュニティです。
https://admin-guild.slack.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away