はじめに
前回は全体の状況や問題点などの紹介でした。今回からは実際に行った対応について紹介していこうと思います。
前回の記事はこちら
対応内容
移行ビルドの検証
利用できれば可能な限り工数少なく暫定対応ができるのではと考え、まずは@vue/compatを使用した移行ビルドの検証を行いました。(移行ビルドとは、既存コードをVue2のコードのままVueのバージョンを3.Xとすることが可能となる、Vueの公式で提供されているツールとなります)
結果として移行ビルドの使用は断念しました。理由としては、大きく以下の二点ありました。
- Vue関連のライブラリの更新、それに伴うコードの修正が必要
- 基本的なUIパーツ(ボタンやダイアログなど)に外部ライブラリを使用しており、こちらもVue3向けへの更新が必要。しかし対象のライブラリのスタイルを独自に変更して使用していた。そのためVue3向けのものに更新するなら、同様の見た目になるよう再度スタイルの調整が必要かつ、すべてのコンポーネントの動作検証が必要
特に2点目の問題が大きく、結果的に全コンポーネントの検証が必要ならVue3向けにコードを書き換えてしまう方が無駄が少なく、後々のためにもよいだろうとなったためです。
コンポーネントの書き換え(Options API→Composition API)
今回元となるコードはすべてOptions APIかつVue Class Component、Vue Property Decoratorを使用した形式でした。約500ファイルほどを手分けしてすべて書き換えていきました。
Composition APIへの書き換えに関しては基本的に公式サイトの内容にしたがって書き換えています。
https://ja.vuejs.org/guide/introduction.html
主な内容は以下の通りです。
- リアクティブな変数はref()
reactive()はいくつか制限があるためrefを使用するようにしています。また既存ではメンバ変数として管理していたものの、リアクティブである必要の無い変数は、判断つく範囲で通常の変数として定義するようにしました。
public data: DataObject = { ... };
private tempCount = 0;
↓
const data = ref<DataObject>({});
let tempCount = 0;
- getter,setterとしていたリアクティブなものはcomputed
classスタイルでは無くなるため、リアクティブである必要のあったgetterなどはcomputedに置き換えています。
get displayData() { return "~"; }
↓
computed(() => {
return "~";
});
- メンバ関数となっていたものを通常のfunctionの定義に変更
export default class Components extends Vue {
public async handleUpdate() {
}
}
↓
async function handleUpdate() {
}
- props,emitをinterfaceで定義
TypeScriptを使用しているということもあり、propsやemitはinterfaceを使用して定義するようにしています。
コンポーネント props の型付け
コンポーネントの emit の型付け
props
@Prop({ required: true })
data!: DataObject;
@Prop({ required: false })
option?: Option;
↓
interface Props {
data: DataObject;
option?: Option;
}
const props = withDefaults(defineProps<Props>(), {
options: {}
});
emit
public handleUpdate() {
this.$emit("update");
}
↓
interface Emits {
(event: "update"): void;
}
const emit = defineEmits<Emits>();
function handleUpdate() {
emit("update");
}
- Mixinやstoreの呼び出しをcomposable形式のものに置き換え
Mixinで取り込んだ中のメンバーで定義されていたものや、thisで$elなど参照していた部分は、関数呼び出し時に引数で明示的に渡すようにしています。Mixin用に作成していた処理に関しては、そもそもの数が少ないかつ大して複雑なものも無かったため、大きな問題なく置き換えができました。
export default class Components extends Mixin(Logic, Store) {
public async handleUpdate() {
const result = await this.logic.doSomething();
await this.store.dataStore.fetch(result);
}
}
↓
<script setup lang="ts">
interface Props extends LogcProps {
data: DataObject;
}
const props = withDefaults(defineProps<Props>(), {});
const { doSomething } = useLogic(props);
const dataStore = useDataStore();
async function handleUpdate() {
const result = await doSomething();
await dataStore.fetch(result);
}
</script>
双方向バインド用のプロパティ生成簡略化の対応
本プロダクトコードでは双方向バインディングを多用しており、オブジェクトを指定する際の処理簡略化のため、以下のようなコードでgetterとsetterを自動で生成していました。
<template>
<Form>
<InputItem v-model="userName"/>
<InputItem v-model="email"/>
</Form>
</template>
export default class Component extends Vue {
@Prop({ required: true })
value!: User;
created() {
for (const key of Object.keys(this.value)) {
Object.defineProperty(this, key, {
get: () => {
return this.value[key as keyof User];
},
set: (value: any) => {
this.$emit("input", { ...this.value, ...{ [key]: value } });
}
});
}
}
}
これだとVue3でclassを使用しなくなったときに同じことができませんでした。
しかしオブジェクトのプロパティをすべてcomputedで定義しなおすのは、今後のことも考えて避けたいという状況。
そこで必要なのはオブジェクトのプロパティ分のgetterとsetterを持ったcomputedの定義ということで、以下のように受け取ったオブジェクトをベースにcomputedを作成する関数を作成しました。
(なおsetのnewValueの型のみ、Tがとりうるすべての型の複合型になっており、型が厳密ではないものの妙案思いつかず。これでも困るケースはなかったので、この形としています……)
export default function usePropertyMapper<T extends object>(
obj: T,
getter: (key: keyof T) => any,
setter: (key: keyof T, newValue: T[keyof T]) => void,
excludes?: (keyof T)[]
): { [key in keyof T]-?: WritableComputedRef<T[key]> } {
const mapper: { [key: string]: WritableComputedRef<any> } = {};
const keys = Object.keys(obj) as (keyof typeof obj)[];
keys.forEach(key => {
if (excludes?.includes(key)) return;
const property = computed({
get: () => getter(key),
set: newValue => setter(key, newValue)
});
mapper[key as string] = property;
});
return mapper as {
[key in keyof T]-?: WritableComputedRef<T[key]>;
};
}
これを各利用箇所で以下のように使用することで、大きな変更なくComposition APIに置き換えることができました。
<template>
<Form>
<InputItem v-model="mapper.userName"/>
<InputItem v-model="mapper.email"/>
</Form>
</template>
interface Props {
modelValue: User;
}
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
(event: "update:modelValue", value: User): void;
}
const emit = defineEmits<Emits>();
const mapper = usePropertyMapper(
props.modelValue,
key => props.modelValue[key as keyof User],
(key, newValue) => {
emit("update:modelValue", { ...props.modelValue, ...{ [key]: newValue } });
}
);
一点だけ注意が必要なのが、emit時に今propsで渡されているオブジェクトの特定のプロパティのみを上書きしたものを親に渡すため、一度の操作で二つ以上のプロパティが同時に変更される際、あとからemitした方が直前の値を古い値で上書きしてしまう点です。
少数ですが当該処理のあるコンポーネントがあり、それぞれが同タイミングでemitしてしまわないよう対応が必要でした。
まとめ
今回は主にVueのコンポーネントの書き換えに関してでした。
Mixinで取り込んで使用していたユーティリティのような共通処理をどのようにしてComposition API向けにするかについては、考えることが多かったですが、それ以外はひたすら手を動かして書き換えていくのみでした。
なるべく機械的に置き換えられるようにしていましたが、対象ファイル数が多く骨の折れる作業でした。