背景
先日、下の投稿でVueコンポーネントのカスタム要素化を試して、無事に親(index.html)から呼び出し可能であることまでは確認しました。
今回は、カスタム要素化したコンポーネントの親子間、子同士でデータのやり取りをしたい場合はどうするのか、について調べてみました。
準備
前回作った以下の3つのサンプルをそれぞれS3で静的ホスティング。
- シンプルなVueのComponentをカスタム要素化したサンプル
- Element-UIを使っているComponentをカスタム要素化したサンプル
- Vuetifyを使っているComponentをカスタム要素化したサンプル
インデックスドキュメントに「bundle.js」を指定することで、それぞれ以下のような形でwebアクセスすればbundle.jsの中身が返ってくるようにしました。
http://simple-vue.s3-website-ap-northeast-1.amazonaws.com
http://simple-element-ui.s3-website-ap-northeast-1.amazonaws.com
http://simple-vuetify.s3-website-ap-northeast-1.amazonaws.com
これらを↓で調べたnuxt-serverless環境(といっても今回動作させるのはローカルのみ)から呼び出して表示してみます。
nuxt-serverlessのREADMEによると、/pagesにはエイリアスのみ記載したtsファイルを置いて、実際のvueファイルは/services/${serviceName}/pages/に置くみたい。
とりあえず、最初からあるtypescript.vueの中身を編集。
<template>
<div class="page-typescript">
<h1>{{ greeting }}</h1>
<vce-button></vce-button>
<hr />
<vce-table
prop1="1"
prop2="example text"
prop3="true"
string-prop="123"
boolean-prop="false"
number-prop="123"
long-prop-name="long name"
></vce-table>
<hr />
<vce-tree></vce-tree>
</div>
</template>
<script lang='ts'>
import { Component, Vue } from "nuxt-property-decorator";
@Component
export default class PageTypescript extends Vue {
greeting: string = "Hello, TypeScript!";
head() {
return {
title: "Hello, TypeScript",
script: [
{
src: "http://simple-vue.s3-website-ap-northeast-1.amazonaws.com"
},
{
src:
"http://simple-element-ui.s3-website-ap-northeast-1.amazonaws.com"
},
{
src: "http://simple-vuetify.s3-website-ap-northeast-1.amazonaws.com"
}
]
};
}
}
</script>
画面はこんな感じ
> yarn dev
超シンプルなMicro Frontends環境が出来上がりました。
ちなみに、S3のbundle.jsを差し替えれば、親を再ビルドすることなくブラウザの再読み込みで子要素が更新されます。
要するに、それぞれのコンポーネントが独立してデプロイ(リリース)可能ということです。
データ通信
データ通信は以下の3つのデータのやり取りを確認。
- 親から子にデータを送る
- 子から親にデータを送る
- 子から子にデータを送る
親から子にデータを送る
動的にデータ通信できることを確認したいので、親側のh1要素へのマウスオーバー/マウスリーブで、データが変わるようにし、その情報をボタンコンポーネントに送ってみる。
h1要素にmouseoverとmouseleaveのv-onを追加し、scriptタグ内にそれぞれイベント検知時のメソッドであるmouseover()とmouseleave()を追加。
また、scriptタグ内にmessageを定義し、mouseover()とmouseleave()内で値を変更する。
vce-buttonタグに「:message="message"」を追加し、messageの中身を子要素に送るようにして、親側の変更は完了。
<template>
<div class="page-typescript">
<h1 v-on:mouseover="mouseover" v-on:mouseleave="mouseleave">{{ greeting }}</h1>
<vce-button :message="message"></vce-button>
<hr />
...
</div>
</template>
<script lang='ts'>
...
@Component
export default class PageTypescript extends Vue {
...
message = "off";
...
mouseover() {
this.message = "on";
}
mouseleave() {
this.message = "off";
}
}
</script>
子要素側は以下。
propsでmessageを受け取り、ボタン内に表示する。
<template>
<div>
<button>{{ text }} {{ message }}</button>
</div>
</template>
<script>
export default {
name: "button",
props: {
message: String,
},
data() {
return {
text: "button",
};
},
};
</script>
小さくて分かりづらいですが、ボタン内の文字列が微妙に変わっているのが分かるかと思います。
子から親にデータを送る
今度は子コンポーネントから親コンポーネントにデータを送ってみる。
子コンポーネントは一カ所だけ修正。
buttonタグにクリックイベントを追加して、親のclickedイベントを呼び出し。
<template>
<div>
<button @click="$emit('clicked', 'クリック')">{{ text }} {{ message }}</button>
</div>
</template>
...
親側はvce-button要素に子から呼び出されるclickedのイベントを追加して、その際の処理としてhandlerファンクションを追加。
handler内でmessageを更新しているので、これが子コンポーネントに伝わり、ボタンのラベルが変わるはず。
<template>
<div class="page-typescript">
...
<vce-button :message="message" @clicked="handler"></vce-button>
...
</template>
<script lang='ts'>
...
handler(event) {
this.message = event.detail[0];
}
}
</script>
子コンポーネントがclickedイベントを呼び出す際に引数として渡したデータの取り出し方がなんか通常と違っているような気がしますが(カスタム要素化したから??)、console.log(event)で確認したら、CustomEvent.detailに配列で入っていたので、上記のような取り出し方をしてみました。
正しいやり方なのかは分かりませんが、データを送るという目的は達成。
子から子にデータを送る
以下のサイトによると、3種類のやり方があるらしい。
- Use a global EventBus
- Use a simple global store
- Use the flux-like library Vuex
1はあまり推奨されないとのことなので、とりあえず3を試したが、結局上手くいかず。
それぞれのコンポーネントでStoreを使うことはできるが、それぞれ別々のStoreになってしまい、データを共有することができないという状況。
カスタム要素化していることが原因か、他所でデプロイしたものを読み込んでいるのが原因かわからないが、インスタンスがうまく共有できていないことが原因なのであれば、1や2の方法でも厳しいかもしれない。
もう少し時間をかけて調べれば、やり方が見つかるのかもしれないけど、疲れたので、正攻法でのデータ送信は一旦諦めることに。。。
子から子にデータを送る (代替案)
これまでの確認で親を経由すれば実現できるのは明らかなので、親を活用したやり方を考えてみる。
ただ、親コンポーネントが子コンポーネントの実装にいちいち関与してたら、独立してデプロイできるようにした意味がないので、対象データの送信元と利用先だけがそれに関わる実装をすれば良いという状況を作りたい。
イメージはこんな感じ。
- 親コンポーネントでデータを一元管理(枠だけ用意するイメージ)。
- データはkey/valueのオブジェクトで、親コンポーネントは中のkeyやvalueに関与しない。
- 親コンポーネントにデータ更新用のメソッドを作成し、引数でkeyとvalueを受け取ってデータを更新する。
- 子コンポーネントがkeyとvalueを指定して、カスタムイベント経由で親に定義した更新用のメソッドを呼び出す。
- データが更新されるとVueのリアクティブデータの機能で、すべての子コンポーネントに更新後のデータを送信する。
- 子コンポーネントは親コンポーネントから、オブジェクトのデータを受け取り、自分が必要な要素のkeyを指定して値を取り出す。
とりあえず思いついた案を試してみる。
まず親コンポーネントから。
<template>
<div class="page-typescript">
...
<vce-button
...
:param="getParam"
@commit="commit($event)"
></vce-button>
...
<vce-table
...
:param="getParam"
@commit="commit($event)"
></vce-table>
...
<vce-tree :param="getParam" @commit="commit($event)"></vce-tree>
</div>
</template>
template内のすべての子コンポーネントのタグに、何も考えずに以下の2つを追加する。
「:param="getParam()"」
「@commit="commit($event)"」
上がデータ配布用。
下がデータ更新用。
※今回、データはparamという名前で扱うことにした。
次がscript部分。
<script lang="ts">
...
@Component
export default class PageTypescript extends Vue {
...
param: any = {};
...
get getParam() {
return JSON.stringify(this.param);
}
commit(event) {
this.$set(this.param, event.detail[0], event.detail[1]);
}
}
</script>
「param: any = {};」でparamという名前でデータを定義。
「get getParam()」はデータを文字列化して返している。
最初はオブジェクトのまま渡そうかと思っていたが、カスタム要素化しているためか、子コンポーネントに渡った時に[object Object]という文字列になってしまうので、仕方なくこうした。
なお、算出プロパティにしないと、無駄に子コンポーネントの数だけ実行されてしまう。
「commit(event)」は子コンポーネント側が渡してきたkeyとvalueをparamに登録している。
vueはオブジェクトの要素追加や削除をリアクティブに検知できないので、単純にthis.paramに要素を追加するのではなく、this.$set()を使って要素を追加している。
続いて、データを更新する子コンポーネント。
以前、カスタム要素化したツリーのコンポーネントを使って、ツリーの要素が選択されたら、親のイベントを呼んでデータを更新するようにしてみる。
<template>
<div id="app">
<v-app id="inspire">
<v-treeview
...
@input="handler"
></v-treeview>
</v-app>
</div>
</template>
vuetifyのツリー要素選択のイベントがinputということだったので、「@input="handler"」でhandler()メソッドを呼ぶようにしている。
続いてscript。
<script>
...
export default {
...
methods: {
handler(selected) {
this.$emit("commit", "key", selected);
},
},
props: {
param: String,
},
...
};
</script>
methodsにhandlerメソッドを追加。引数のselectedには、選択しているツリー要素のIDが配列で入っている(vuetifyが入れてくれる)。
$emit()で親コンポーネントのcommitイベントを呼び出している。
propsにparamを追加しているが、今回は使っていないのであまり意味はない。
続いて、データを受け取る側の子コンポーネント。
またボタンコンポーネントを利用。
<template>
<div>
<button @click="$emit('clicked', 'クリック')">
{{ text }} {{ message }} {{ getParam }}
</button>
</div>
</template>
{{ getParam }}を追加しただけ。
script部分。
<script>
export default {
name: "button",
props: {
message: String,
param: String,
},
data() {
return {
text: "button",
param: "",
};
},
computed: {
getParam() {
var map = JSON.parse(this.param);
return map["key"];
},
},
};
</script>
propsにparam: Stringを追加して親から受け取れるようにしている。
ちなみに、String部分をObjectにすればオブジェクトのまま受け取れるかと思ったが、駄目だった。
dataにもparamを追加。propsにあるからいらねんじゃね?と思ったが、他でthis.paramを呼び出したときに怒られたので、追加した。これは、もっと他に上手いやり方がありそう。
最後、computedにgetParam()を追加。this.paramは文字列として渡ってくるので、オブジェクト化した後に、指定したキーに対応する値を取得して返している。
同様にして、別の子コンポーネントでも同じデータを受け取れるようにした。
(一つ目の子コンポーネントにしかデータが送れないということがありそうな気がしたので。)
<template>
<div class="card card--primary">
<h4>{{ message }} {{ getParam }}</h4>
...
</div>
</template>
<script>
export default {
props: {
param: String,
...
},
data() {
return {
message: "Custom Element By Vue + Element UI",
param: "",
};
},
computed: {
getParam() {
var map = JSON.parse(this.param);
return map["key"];
},
...
},
...
};
</script>
説明は省略。
動作確認してみる。
見づらいですが、一応、ツリーで選択した要素のIDが、ボタンのコンポーネント(ボタンのラベル)とテーブルのコンポーネント(タイトル部分の横)に動的に表示されています。
さいごに
今回はWeb Components間のデータ通信について調べてみました。
Custom Element化(さらに呼び出し部分を疎結合に)したことで、通常のVueのコンポーネントと異なる動作をする部分があり、なかなか上手くいきませんでした。
特に子コンポーネント間のデータ通信は、Vuexなどによっていい感じにデータを管理できないか、もうちょっと調べてみたいところです。
最後の「子から子にデータを送る (代替案)」については、Vuexのようにどのコンポーネントからもアクセスできるようにはなっていませんが、親コンポーネント側は最初に今回の実装を入れてしまえば、後はノータッチでいけるので、負担は幾分か減ると思います。
ただ、利用のルールは決めておかないと、キーが重複したりとか問題が発生しそうですね。
まあ、わざわざコンポーネントを疎結合にしているわけだから、各コンポーネントで必要な"状態"は各コンポーネント内で持ってもらって、他とのデータのやり取りは最低限にすれば、そんなに大変なことにならない気もしますが…。
あと、親コンポーネントが普通のVueコンポーネントも一緒に抱えるなら、データを一元管理する部分はVuexとか使ってもいいかもです。
2020/06/06 追記
子コンポーネント間のデータ通信は、CustomEventを使ってシンプルにやる方法があることを教えてもらったので試してみた。
ツリー側のhandlerメソッドを下記のように修正して、カスタムイベントを生成。
<script>
...
export default {
...
methods: {
handler(selected) {
var event = new CustomEvent("test", { detail: selected });
window.dispatchEvent(event);
},
}
...
};
</script>
ボタン側のmountedでイベントリスナーを追加し、methods内に定義したメソッドでデータを書き換え。
<script>
...
export default {
...
mounted: function() {
window.addEventListener("test", this.update, false);
},
methods: {
update: function(e) {
this.text = JSON.stringify(e.detail);
}
},
...
};
</script>
シンプルにデータ通信することが出来ました!