LoginSignup
2
1

More than 3 years have passed since last update.

VueコンポーネントでObjectを双方向バインディングする際の実装

Last updated at Posted at 2019-07-05

やりたいこと

バックエンドから構造化されたデータを受け取る。
それを表示・編集するフォームをコンポーネント化したい。
配列や下位オブジェクトも分割できるようにしたい。

こんなJSONが返ってくるとする。

invoice.json
{
  "number": "I-201907-01",
  "billingDate": "2019-07-10",
  "customer": {
    "name": "株式会社あいうえお",
    "address": "1234567 東京都千代田区神田1-1-1"
  },
  "details": [
    {
      "id": 1,
      "item": "商材1",
      "price": 10000,
      "qty": 10
    },
    {
      "id": 2,
      "item": "商材2",
      "price": 5000,
      "qty": 3
    }
  ]
}

ルートコンポーネント

Page.vue
<template>
  <div>
    <sandbox-form
      :model="model"
    ></sandbox-form>
    <button
      @click="save"
    >保存</button>
  </div>
</template>

<script>
  import SandboxForm from './SandboxForm.vue';

  export default {
    name: "Page",
    components: {
      SandboxForm,
    },
    data () {
      return {
        model: {
          customer: {},
          details: [],
        },
      };
    },
    async created () {
      this.model = await (await fetch('/invoice.json')).json();
    },
    methods: {
      save () {
        console.log('save', this.model);
      },
    },
  }
</script>

子コンポーネント

SandboxForm.vue
<template>
  <form>
    <div>
      <label for="number">Number</label>
      <input
        id="number" type="text"
        v-model="model.number"
      />
    </div>
    <div>
      <label for="billing-date">Billing Date</label>
      <input
        id="billing-date" type="date"
        v-model="model.billingDate"
      />
    </div>
    <fieldset>
      <legend>Customer</legend>
      <div>
        <label for="customer-name">Name</label>
        <input
          id="customer-name" type="text"
          v-model="model.customer.name"
        />
      </div>
      <div>
        <label for="customer-address">Address</label>
        <input
          id="customer-address" type="text"
          v-model="model.customer.address"
        />
      </div>
    </fieldset>
    <fieldset>
      <legend>Details</legend>
      <sandbox-form-detail
        v-for="(detail, index) in model.details"
        :key="detail.id"
        :model="detail"
      >
      </sandbox-form-detail>
    </fieldset>
  </form>
</template>

<script>
  import SandboxFormDetail from './SandboxFormDetail.vue';
  export default {
    name: "SandboxForm",
    components: {
      SandboxFormDetail,
    },
    props: {
      model: Object,
    },
  }
</script>

孫コンポーネント

SandboxFormDetail.vue
<template>
  <div>
    <label for="item">Item</label>
    <input
      id="item" type="text"
      v-model="model.item"
    />
    <label for="price">Price</label>
    <input
      id="price" type="text"
      v-model="model.price"
    />
    <label for="qty">Qty</label>
    <input
      id="qty" type="text"
      v-model="model.qty"
    />
    <label for="item-total">Item total</label>
    <input
      id="item-total" type="text" readonly
      :value="itemTotal"
    />
  </div>
</template>

<script>
  export default {
    name: "SandboxFormDetail",
    props: {
      model: Object,
    },
    computed: {
      itemTotal () {
        return this.model.price * this.model.qty;
      }
    },
  }
</script>

一応動く。

問題点

  • 子コンポーネントで親のプロパティを直接変更している。
    公式ドキュメントを読む限りNGらしい。プリミティブ値の場合、警告も出る。
    単方向のデータフロー
    (絶対NGなのか、場合によってはOKなのか程度がイマイチ分からない。最初はイベントでの受け渡しを実装する手間に見合わないと思っていた)

  • 親コンポーネントで階層構造を初期化しないと、子コンポーネントでネストしたプロパティを触った際にエラーが出る。

    data () {
      return {
        model: {
          customer: {}, // この下位オブジェクトを作っていないと
          details: [],
        },
      };
    },
<template>
...
        <input
          id="customer-name" type="text"
          v-model="model.customer.name" // これでエラー
        />

実装

コンポーネントでv-modelを使う
sync-修飾子

この辺りを参考に、props down event upを実装する。

modelProp.js
import _ from 'lodash';

export default {
  props: {
    model: Object,
  },
  methods: {
    get (key) {
      return _.get(this.model, key);
    },
    set (key, value) {
      this.$emit(`update:model`, _.set(_.cloneDeep(this.model), key, value));
    },
  }
}

まずpropsで受け渡すmodelプロパティを定義する。
modelを触る際はgetter/setter経由にするため、メソッドを追加。
getterでネストしたプロパティにアクセスする際の問題を解決。
setterはmodelを変更せずに、deep copyしたオブジェクトに変更を加えて、update:modelイベントを発火する。
deep copyは自前で実装するのが面倒なのでlodashを使用した。
複数のコンポーネントで使いまわすのでmixinできるように別ファイルに切り出した。

ルートコンポーネント

Page.vue
<template>
  <div>
    <sandbox-form
      :model.sync="model"
      <!-- ↑ .sycnを追加 -->
    ></sandbox-form></div>
</template>

<script>
  import SandboxForm from './SandboxForm.vue';

  export default {
    name: "Sandbox",
    components: {
      SandboxForm,
    },
    data () {
      return {
        model: {}, // 下位オブジェクトの初期化が不要に
      };
    },

  }
</script>

子コンポーネント

SandboxForm.vue
<template>
  <form>
    <div>
      <label for="number">Number</label>
      <input
        id="number" type="text"
        :value="get('number')" @input="set('number', $event.target.value)"
        <!-- ↑ v-modelを分解してgetter/setterを通すように変更 -->
      />
    </div>
    <div>
      <label for="billing-date">Billing Date</label>
      <input
        id="billing-date" type="date"
        :value="get('billingDate')" @input="set('billingDate', $event.target.value)"
      />
    </div>
    <fieldset>
      <legend>Customer</legend>
      <div>
        <label for="customer-name">Name</label>
        <input
          id="customer-name" type="text"
          :value="get('customer.name')" @input="set('customer.name', $event.target.value)"
        />
      </div>
      <div>
        <label for="customer-address">Address</label>
        <input
          id="customer-address" type="text"
          :value="get('customer.address')" @input="set('customer.address', $event.target.value)"
        />
      </div>
    </fieldset>
    <fieldset>
      <legend>Details</legend>
      <sandbox-form-detail
        v-for="(detail, index) in get('details')"
        :key="detail.id"
        :model="detail" @update:model="set(`details.${index}`, $event)"
        <!-- ↑ .syncだと更新時にsetterを通せないので、@update:modelイベントを購読。
                コンポーネントの場合はネイティブと違って$eventに値が直接入ってくる -->
      >
      </sandbox-form-detail>
    </fieldset>
  </form>
</template>

<script>
  import modelProp from './modelProp';
  import SandboxFormDetail from './SandboxFormDetail.vue';

  export default {
    name: "SandboxForm",
    components: {
      SandboxFormDetail,
    },
    mixins: [ modelProp ], // props, getter/setterをmixinで追加
  }
</script>

孫コンポーネント

SandboxFormDetail.vue
<template>
  <div>
    <label for="item">Item</label>
    <input
      id="item" type="text"
      :value="get('item')" @input="set('item', $event.target.value)"
    />
    <label for="price">Price</label>
    <input
      id="price" type="text"
      :value="get('price')" @input="set('price', $event.target.value)"
    />
    <label for="qty">Qty</label>
    <input
      id="qty" type="text"
      :value="get('qty')" @input="set('qty', $event.target.value)"
    />
    <label for="item-total">Item total</label>
    <input
      id="item-total" type="text" readonly
      :value="itemTotal"
    />
  </div>
</template>

<script>
  import modelProp from './modelProp';

  export default {
    name: "SandboxFormDetail",
    mixins: [ modelProp ],
    computed: {
      itemTotal () {
        return this.get('price') * this.get('qty'); // computedもちゃんと反応する
      }
    },
  }
</script>

注意点

  • model更新のイベントは非同期なので、メソッド内で複数発行すると意図した動作をしない。
<script>
  import modelProp from './modelProp';

  export default {
    name: "XxxForm",
    mixins: [ modelProp ],
    methods: {
      exec () {
        this.set('val1', 1);
        this.set('val2', 2);
        this.set('val3', 3);
        // ↑ 変更前のmodelに同時に更新イベントが発火するので、どれか一つしか反映されない
      }
    },
  }
</script>
<script>
  import modelProp from './modelProp';

  export default {
    name: "XxxForm",
    mixins: [ modelProp ],
    methods: {
      async exec () {
        await this.set('val1', 1);
        await this.set('val2', 2);
        await this.set('val3', 3);
        // ↑ async/awaitで反映を待ってから実行すればOK
      }
    },
  }
</script>
  • ルートコンポーネントでv-forを使って配列の中身を子コンポーネントに渡す場合、インデックスを指定しないと.syncで更新できない。
Page.vue
<template>
  <div>
...
    <sandbox-form-detail
      v-for="(detail, index) in model.details"
      :key="detail.id"
      :model.sync="detail" <!-- これはNG -->
    ></sandbox-form-detail>
  </div>
</template>
Page.vue
<template>
  <div>
...
    <sandbox-form-detail
      v-for="(detail, index) in model.details"
      :key="detail.id"
      :model.sync="model.details[index]" <!-- これだとOK -->
    ></sandbox-form-detail>
  </div>
</template>

まとめ

内容的には公式に書いてあることの組み合わせだけど、Objectを扱う例が載っていないので盛大にハマった。
間違った方向でやってそうな気もするので、もっと良いやり方があれば教えてください。

参考

v-modelにオブジェクトをバインディングする場合のコンポーネント実装を考える
Vue.js: Using v-model with objects for custom components

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1