SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

プロダクトのパフォーマンスを改善するためにVue.jsの関数型コンポーネントやpropsに関する施策を行った話

こんにちは!フリーランスエンジニアとしてスマートキャンプに参画している芳岡です。

弊社のプロダクトであるBiscuet(https://biscuet.jp/)の開発に初期から参画していますが、サービスが世の中に展開されていく過程、チームが大きくなっていく過程を間近で見れとても興味深く思っています。

今回は、そのBiscuetで使用しているVue.jsのパフォーマンス改善を行ったのですが、そこで気づいたいくつかのポイントを整理してお届けします。

Vue.jsのパフォーマンス

Vue.jsでは、状態変更によって、仮想DOMが繰り返し再計算(updateRender)されるため、それによりパフォーマンスが悪くなることがあります。

再計算に百msもかかってしまうと、もっさりした動作となりユーザにも印象がよくありません。

そこで、再計算の回数、つまりVue.jsのライフサイクルでいうところのupdateの回数を減らして改善を試みます。

ポイントは以下の2点となるのでそれぞれ説明していきます。

  • 関数型コンポーネント
  • propsは、オブジェクト全体か各プロパティごとか?

関数型コンポーネント

関数型コンポーネントについての公式な解説は、こちらをご覧ください。

jp.vuejs.org

通常のコンポーネントと関数型コンポーネントを比較するため、千個のボタンコンポーネントを並べました。

このボタンを関数型コンポーネントにした時の表示時の処理速度を比較したいと思います。

f:id:yuxyosh:20200416180017p:plain
ボタン1000個

結果はこちら

f:id:yuxyosh:20200416181327p:plain
通常のコンポーネント
f:id:yuxyosh:20200416181411p:plain
関数型コンポーネント

クリックしたら描画されるようにしたので、クリックイベントの時間を見ています。

通常のコンポーネントが153msかかっているのが、関数型コンポーネントで71msになりました。約半分ですね。

ボタン一個あたりだと、82μsの短縮です! まあ、μsを体感できる方はいないと思いますが...。

ひとつひとつでは小さな差ですが、ボタンだけでなくアイコンや、コンテナの機能を持つコンポーネントなど、出来るだけたくさんのコンポーネントを関数型コンポーネントとすれば、塵も積もれば山となりパフォーマンスアップに繋がっていきます。

最後に、それぞれのボタンコンポーネントのコードです。

ノーマルなコンポーネント

<template>
  <button class="btn" @click="click" :style="styles">
    {{ label }}
  </button>
</template>

<script>
export default {
  props: {
    label: String,
    color: String
  },
  computed: {
    styles() {
      return {
        "background-color": this.color
      };
    }
  },
  methods: {
    click() {
      this.$emit("click");
    }
  }
};
</script>

<style scoped>
.btn { width: 50px; }
</style>

関数型コンポーネント

<template functional>
  <button class="btn" :style="$options.styles(props)" v-on="listeners">
    {{ props.label }}
  </button>
</template>

<script>
export default {
  props: {
    label: String,
    color: String
  },
  styles(props) {
    return {
      "background-color": props.color
    };
  }
};
</script>

<style scoped>
.btn { width: 50px; }
</style>

差分は以下です。

  • templateタグに、functional属性を付ける。

  • propsで渡された値は、props.プロパティ名で呼び出す。

  • computedmethodsはなくなり、直接関数を定義する必要がある。また、定義した関数は$options.関数名で呼び出す。

  • 下位コンポーネントから上位のコンポーネントへイベントを上げるため、v-on="listeners"を設定する。

ここでは、例として挙げたため、propsの数が2つですが、propsの数が多いほうが、処理時間の差は大きくなります。

propsは、オブジェクト全体か各プロパティごとか?

親から子コンポーネントに、propsを通して、値を渡すときどのようにしてますか? オブジェクトで渡してますか、それともオブジェクトのプロパティを個別に渡していますか?

やり方によってはupdateの回数に大きく差がでる場合があります。

以下のコードではパフォーマンスの差を確認するために300個のボタンを並べてます。

プロパティごとに渡す方法

親コンポーネントのコード

<template>
  <div>
    <div>
      <p>
        <button @click="changeProperty">プロパティ変更</button> |
        <button @click="changeObjectRef">参照変更</button>
      </p>
      <p>
        オブジェクト:{{ obj }}
      </p>
    </div>
    <normal-btn :label="aObject.label"
                v-for="i in list" :key="`a-${i}`" />
    <normal-btn :label="bObject.label"
                v-for="i in list" :key="`b-${i}`" />
    <normal-btn :label="cObject.label"
                v-for="i in list" :key="`c-${i}`" />
  </div>
</template>

<script>
import NormalBtn from "@/components/NormalBtn";

export default {
  components: {
    NormalBtn
  },
  data() {
    return {
      list: [...Array(100).keys()],
      obj: { a: { deep1: { deep2: 1 } }, b: 2, c: 3 }
    };
  },
  computed: {
    aObject() {
      return { label: this.obj.a.deep1.deep2.toString() };
    },
    bObject() {
      return { label: this.obj.b.toString() };
    },
    cObject() {
      return { label: this.obj.c.toString() };
    }
  },
  methods: {
    changeProperty() {
      this.obj.a.deep1.deep2 = this.obj.a.deep1.deep2 + 1;
    },
    changeObjectRef() {
      this.obj = Object.assign({}, this.obj);
    }
  }
};
</script>

子コンポーネント(NormalBtn)のコード

<template>
  <button class="btn" @click="click" :style="styles">
    {{ label }}
  </button>
</template>

<script>
export default {
  props: {
    label: String,
    color: String
  },
  computed: {
    styles() {
      return {
        "background-color": this.color
      };
    }
  },
  methods: {
    click() {
      this.$emit("click");
    }
  }
};
</script>

<style scoped>
.btn {
  width: 50px;
}
</style>

親側で、ひとつの大元のオブジェクト(変数名:object)から、computedでaObject, bObject, cObjectという派生したオブジェクトを作成してます。

この派生したオブジェクトのプロパティ(String)を子コンポーネントに渡しています。

さて、ここで大元のオブジェクト(変数名:object)を変更したらどのようになるでしょうか。子コンポーネントはupdateされますか?

答えは、propsに渡された値が変更された時だけ、その子コンポーネントがupdateされます。逆に、propsに変更がない子コンポーネントはupdateされません。

変数objectの参照をまるっと入れ替えても、同じプロパティ値であればupdateされません。(なお、プロパティ値が同じでも、参照が入れ替わった場合は、親コンポーネントのcomputedは再計算されるので注意してください)

オブジェクトを渡す方法

次に親コンポーネントから子コンポーネントにオブジェクトを渡す場合です。

親コンポーネントのコード

<template>
  <div>
    <div>
      <p>
        <button @click="changeProperty">プロパティ変更</button> |
        <button @click="changeObjectRef">参照変更</button>
      </p>
      <p>
        オブジェクト:{{ obj }}
      </p>
    </div>
    <object-prop-btn :object="aObject"
                     v-for="i in list" :key="`a-${i}`" />
    <object-prop-btn :object="bObject"
                     v-for="i in list" :key="`b-${i}`" />
    <object-prop-btn :object="cObject"
                     v-for="i in list" :key="`c-${i}`" />
  </div>
</template>

<script>
import ObjectPropBtn from "@/components/ObjectPropBtn";

export default {
  components: {
    ObjectPropBtn
  },
  data() {
    return {
      list: [...Array(100).keys()],
      obj: { a: { deep1: { deep2: 1 } }, b: 2, c: 3 }
    };
  },
  computed: {
    aObject() {
      return {
        label: this.obj.a.deep1.deep2.toString()
      };
    },
    bObject() {
      return { label: this.obj.b.toString() };
    },
    cObject() {
      return { label: this.obj.c.toString() };
    }
  },
  methods: {
    changeProperty() {
      this.obj.a.deep1.deep2 = this.obj.a.deep1.deep2 + 1;
    },
    changeObjectRef() {
      this.obj = Object.assign({}, this.obj);
    }
  }
};
</script>

子コンポーネント(ObjectPropBtn.vue)のコード

<template>
  <button class="btn" @click="click" :style="styles">
    {{ object.label }}
  </button>
</template>

<script>
export default {
  props: {
    object: Object,
    color: String
  },
  computed: {
    styles() {
      return {
        "background-color": this.color
      };
    }
  },
  methods: {
    click() {
      this.$emit("click");
    }
  }
};
</script>

<style scoped>
.btn {
  width: 50px;
}
</style>

先に示した「プロパティごとに渡す方法」のコードとほぼ同じです。

違いは、子コンポーネントに渡す値をオブジェクトのプロパティでなく、オブジェクト全体を渡しているところ、子コンポーネントでは、受け取ったオプジェクトのプロパティを使用しているところです。

再度、同じ質問です。ここで大元のオブジェクト(変数名:object)を変更したら、どのようになるでしょうか。子コンポーネントはupdateされますか?

答えは、子コンポーネントで使用するオブジェクトのプロパティが変更されると、その子コンポーネントがupdateされます。

使用するプロパティの値が同じなら、子コンポーネントはupdateされません。

「プロパティごとに渡す方法」との違いは、変数objectの参照が変わった場合です。

内部のプロパティが全て同じであっても関連する子コンポーネントが全てupdateされます! この例でいえば、300個のボタン全て(値が変更されていないものも含め)updateが走ります。

注釈

上で述べたように「オブジェクトを渡す方法」だと不要なupdateが行われる可能性があります。

ですが、言いたいことは「オブジェクトを渡すな」ということではなく、 実際の案件は複雑で、このような事例が紛れ込み易く見つけづらいということです。

Biscuetでも、オブジェクトを子コンポーネント、さらには孫コンポーネントに渡して操作したり、間にstoreを通してデータを変更したりしています。

具体的な修正の方法は様々だと思います(objectの参照を変えないようなロジックにする、関数型コンポーネント等)。

ケースバイケースで、必要な対策を取れば大丈夫だと思いますが、その前にupdate回数が多いところを見つけましょう。

調査はVue.jsのdevtoolsを使います。

ポイントとしては、以下の画像のように値を数箇所変えただけなのにupdatedが数十回〜走っているところは怪しいです。

f:id:yuxyosh:20200416193753p:plain

最後に

プロジェクトの初期から上記のような事項を気にしながら設計することは難しい場合もあると思います。

事後的にこういったパフォーマンスの懸念が出てきた場合でも、まずはコンポーネントの分解から取り掛かりましょう。

AtomicDesignを導入し、機能を複数持った大きなコンポーネントを小さく分解していくことが近道かと思います。

これだけで全て解決とは言えませんが、見通しが良くなることでなにか調子が悪いと感じたときも調査しやすくなるはずです。

以上、長文にお付き合いいただきありがとうございました!