Reactがいいか、Vueがいいか、あるいは第三のフレームワークか…。
みたいな記事を、ちらほら見かけるけど、
ReactとVueの両方を使っている者としては、
どちらも一長一短があり、
新たなフレームワークに乗り換えるにしても、そのあたりをきちんと整理した上で選定した方が良さそうだなと感じているので、
ReactとVueを比較整理しつつ、自分なりの技術選定基準を書いてみようと思います。
まず、前編の今回は、ReactよりもVueの方が良いと思う点について。
Vueの方が良いところ
依存関係を自動で判断してくれる
ReactだとuseMemoやuseCallbackの結果に影響するリアクティブな値を第2引数に明記する必要があり、ここに書き漏れが生じると、更新されるべき値が更新されず、バグに繋がる。
const [rate, setRate] = useState(3);
const star = useMemo(() => {
return new Array(rate).fill('★').join('');
// ↓ 結果を算出するために必要なリアクティブ変数を依存配列に明記する必要
}, [rate]);
この点、Vueは、何が依存しているかを自動で判別して、リアクティブな値の更新を検知して再計算してくれるので、とても良い。
const rate = ref(3);
const start = computed(() => {
return new Array(rate).fill('★').join('');
});
ライフサイクルフックやwatchという概念がわかりやすい
20230815追記
本節については、いただいたコメントより、
ReactとVueの設計思想の違いによるところが大きいように思い直しましたので、
後日改めて整理しなおした記事を書こうと思います。
これを書いた時点ではこう思っていた、ということで一応残しておきます。
ReactのuseEffect問題
Reactでは、上述の依存関係の書き漏れを防ぐためのESLintルールが用意されている。
たとえば、以下のようにuseMemo内で使用しているリアクティブ変数について、依存配列に記入を忘れると、
const [rate, setRate] = useState(3);
const star = useMemo(() => {
return new Array(rate).fill('★').join('');
// 依存配列の記入忘れ
}, []);
以下のようなESLintの警告が出力される。
src\components\Hoge.tsx
Line 32:8: React Hook useMemo has a missing dependency: 'rate'. Either include it or remove the dependency array react-hooks/exhaustive-deps
なので、自分で依存配列を入力する手間はあれども、ESLintを活用すれば、useMemoやuseCallbackでの依存配列の書き漏れは防げる。
ただ、ここで、ESLint警告が表示する変数を、機械的に依存配列に追加していくと、今度は予期せぬuseEffect処理の実行が行われてしまう可能性がある。
たとえば、以下のように「showフラグが立った時にcalculateを実行する」という処理を書いたとする。
const [show, setShow] = useState(false);
const [param, setParam] = useState({...});
const calculate = useCallback(() => {...}, [param]);
useEffect(() => {
// showフラグが立った時にcalculate実行
if (show) {
calculate();
}
}, [show])
このコードでは、useEffectの依存配列にcalculateが入っていないために、ESLintの警告が出る。
そして、その警告を消すために、useEffectの依存配列にcalculateを加えると、警告が消える代わりに、今度は望んでいないタイミング(上記の例だと、paramの値が変わったタイミング)でもuseEffectの処理が動いてしまうようになる。ケースによっては、無限ループが発生することもある。
また、「コンポーネントの生成時に一度だけ実行させたい処理は、useEffectの依存配列を空にすればよい」と公式ドキュメントに書かれているが、以下のようなコードも上記と同様にESLint警告が出力されてしまう。
const initialize = useCallback(() => {...}, []);
useEffect(() => {
initialize();
// ↓ここにinitializeが入っていないことについてESLint警告が出る
}, []);
eslint-disable-next-line
コメントを入れるとESLintは次行を無視してくれるので、じゃあそれで対処しようか、となるが、
「何か警告が出たら、eslint-disable-next-line
コメントを入れる」
みたいなことがプロジェクトメンバーの間で習慣化されると、それも危険だ。
「ESLintを無視するコメントを入れるのではなく、そもそも警告の出ないようなプログラムを書くべきだ」
という意見もあり、
「いやでも、どうやったって、うまく書けないケースはあるだろう」
という意見やら何やらが云々かんぬん…。
useEffectをどう使うべきかの議論は、唯一無二のベストプラクティスは導き出されないまま、今も各所で交わされ続けている。
最近読んだ記事に、
「フレームワークが一般的に提供している機能について、どう使用すればいいかわからなくて困るなんてこと、React以外のフレームワークではないよ」
てなことが書かれていて、まぁその通りだな、と読んで笑った。
→Things you forgot (or never knew) because of React - Josh Collinsworth blog
個人的に、useEffectの問題は「それが何であるか」が定まっていないことに起因しているのではないかと思っている。
「どういう仕組みで動くものか」ではなく、「それが何であるか」。
Vueのライフサイクルフック・watch
Vueでは、ReactでuseEffectがあいまいに担っている責務を、ライフサイクルフックやwatchという概念に落とし込んですっきり整理してくれている。
上記ReactのコードをVueで書くと以下のようになる。
const initialize = () => {};
onMounted(() => {
initialize();
});
const [show, setShow] = useState(false);
const [param, setParam] = useState({...});
const calculate = () => {};
watch(() => show, () => {
if (show) {
calculate();
}
});
どの契機で動く処理なのか、明確。
ちなみに私は、Reactでは、useMountedやuseWatchという名前のカスタムフックを用意して、これを使うことにしている。
export function useMounted(process: () => void) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(process, []);
}
export function useWatch(process: () => void, deps: DependencyList) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(process, deps);
}
const initialize = useCallback(() => {...}, []);
useMounted(() => {
initialize();
});
const [show, setShow] = useState(false);
const [param, setParam] = useState({...});
const calculate = useCallback(() => {...}, [param]);
useWatch(() => {
// showフラグが立った時にcalculate実行
if (show) {
calculate();
}
}, [show])
useMoutedやuseWatchの定義内においてESLint警告を無視する理由は明確だし、
これらを使用する処理側のソースコードでも、それぞれの処理がどの契機で呼ばれるのかがわかりやすくなる。
同様のことを考える人は他にもいるようで、react-useというパッケージでも、useMount等のフックが提供されているのを、少し前に見つけた。
状態管理機能が使いやすい
Vue2時代も、Vuex × vuex-module-decoratorsの組み合わせで結構使いやすかったけれど、
Vue3から標準で導入されたprovide/injectは、さらに使いやすい。
Contextとの比較
ReactのContextとVueのprovide/injectは、下位コンポーネントでの値の取得方法は似ている。
const { value } = useContext(myContext);
const { value } = inject('dataStoreKey');
ただ、おそらくReactのContextは、公式ドキュメントのサンプルにあるように、dark/whiteみたいな簡単な値を上から下に対して一斉に伝播させて、一斉にコンポーネントの見た目を変更するようなケースを主に想定して用意されたものではないかと思われ、状態管理機構として用いるには、以下のような短所がある。
- Context内の値が変わると、配下のコンポーネント全てに再描画が走る。
- Context内の値を変更するためのsetterの定義方法が、ちょっと変。
後者がどういうことかというと。
Contextを定義する際にデフォルト値を定義する必要があり、setterを下位コンポーネントに渡そうとすると、以下のようにいったん、()⇒{}
をデフォルト値として定義する必要がある。
元々はプリミティブ型の値を設定するために用意された雰囲気の場所に、「JavaScriptならFunctionも変数としてセットできるから…」とFunction型を詰め込んでいる感がひしひしとする。
export const MyContext = React.createContext({
a: 0,
setA: () => {} // デフォルト値として空Functionを定義
});
export function Root() {
const [a, setA] = useState(0);
const myContextValue = {
a,
setA // 実際の値として、stateのsetterを設定
}
return (
<MyContext.Provider value={myContextValue}>
...
</MyContext.Provider>
)
}
あと、Contextの型定義を行っている場所と、Contextで実際に扱う値を定義する場所が異なるのも、微妙にわかりづらい。
(上記コードで説明すると、Contextの型定義はRootコンポーネントの外側で行い、Contextで扱う実際の値はRootコンポーネントの中で定義している)
これに対してVueだと、
-
状態管理で管理する値とsetterを定義するフックを用意して、
Vueの場合-状態管理定義export default function useData() { const a = ref(0); const setA = (val) => { a.value = val }; return { a, setA, } }
-
それを下位コンポーネントに向けて伝播する。
Vueの場合-下位コンポーネントへ提供export default defineComponent({ name: 'Root', setup() { const data = useData(); provice('datastore', data);
状態管理の定義を、Vueの通常のフックと同様の形式で記述すればよいので、わかりやすい。
また、管理する値が多くなり、値の種別ごとに分割管理しようとなった際、
ReactのContextでは、以下のようにContextProviderを入れ子で記述する形になり、ちょっとカッコ悪い。
<AContext.Provideer value={aContextValue}>
<BContext.Provideer value={bContextValue}>
<CContext.Provideer value={cContextValue}>
...
Vueの場合は、以下のように並列に列挙すれば良し。
export default defineComponent({
name: 'Root',
setup() {
const dataA = useDataA();
provice('datastoreA', dataA);
const dataB = useDataB();
provice('datastoreB', dataB);
const dataC = useDataC();
provice('datastoreC', dataC);
Reduxとの比較
Contextが簡単な値を伝播する用途に適しているのに対して、Reduxは状態を中央集権的にがっちり管理するイメージの代物。
Vue2での状態管理に使われていたVuexと基本的な構造は似ているが、以下難点。
-
だいぶ煩雑。初期構築時は毎回マニュアル見ながらじゃないと、基本構成ファイル(reducer、thunk)を作るのが難しい。
-
同じ値に関する処理が、reducerとthunkの2ファイルに分離しているため処理を追いづらい。
↑aの値がどこから来た値なのか追う際に、reducer、thunkの2ファイルを参照する必要がある。
-
reducerで管理している値を、共通的に加工する処理は別途カスタムフックを用意して実装する必要がある。
対するVueは、状態管理フックの中にcomputed定義を入れればOK。
Vueの場合export default function useData() { const a = ref(0); const setA = (val) => { a.value = val }; // computedを追加 const customA = computed(() => a * a }; return { a, setA, cusstomA, } }
記述が簡単なことと、近しい処理を近くに書ける、というところが、Reduxと比べてVueが優れているところだと思います。
Recoilとの比較
Recoilは、以下点から、Vueと同等かそれ以上の可能性のある機能です。
- Vueと同様に状態を細かなカテゴリで定義できる
- Vueのcomputed的なことのできるselectorという機能も用意されている
- Suspenseと連動した非同期処理を実装できる
ただ、現状、以下がマイナスポイントかなと。
- Recoil内でフックが使えないので、Contextと併用する場合にブリッジ的な処理を組み込んで、同じ値をRecoilとContextの両方で管理する必要が生じる場合がある。
- 開発体制の雲行きが怪しい…。
というわけで、状態管理についてまとめると、
使いやすい状態管理機構が標準で組み込まれている点が、VueがReactよりも優れているところかなと思います。
~~~~~~~~~~~~~~
以上、Reactと比べてVueの方が優れていると思う点でした。
書きながら、「やっぱ、Vueいいな~」と思ってるんですが、
実は、個人で開発するものに関しては、VueよりもReactを採用することが多く、
そのあたりの理由も踏まえて、次回は、Reactの方が優れている点と、開発開始時の技術選定観点について、書こうと思います。