朝日ネット 開発者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

TypeScript で React + Redux と Vue + Vuex を書き比べる

はじめに

開発部の tasaki です。8 月の記事では TypeScript + React + Redux + React Router + α を使って 1 ファイルで完結する小さいアプリケーションを書きました。

techblog.asahi-net.co.jp

今回は TypeScript + Vue + Vuex + Vue Router の組み合わせで同じものを書き、2 つのライブラリの共通点と違いについて探ってみたいと思います。 このようなものを書いた主な理由は以下の 3 つです。

  • 適度に小さく完備なアプリケーションの構成を作っておき、そのソースコード自体をチートシートとして使いたい
  • React と Vue の共通点や差異について説明をする時に使える実例を用意しておきたい
  • React や Vue を TypeScript から使う際の書き方を確立しておきたい

以下、実際にアプリケーションのソースコードを見ていきます。

ソースコード

リポジトリ

React + Redux 版

  • 動作例: https://ikasat.github.io/url-timer/react
  • index.tsx
  • package.json
  • 前回記事との差異
    • パッケージのバージョンを現時点での最新のものにしています
      • react, react-dom は npm i react@next react-dom@next で入る alpha 版ですが、本編では alpha 版の機能は使っていません
        • 「備考」にて alpha 版の機能である Hooks を使った書き方を紹介しています
      • connected-react-router と react-redux については Issue #205 のため最新版ではなく 1 つ前のバージョンを使っています
    • moment の代わりに luxon を使っています
    • redux-form の代わりに formik を使っています
    • reselect は今回不要だったため外しています

Vue + Vuex 版

実行例

git clone https://github.com/ikasat/url-timer.git
cd url-timer
cd url-timer-react   # React 版
# cd url-timer-vue   # Vue 版
npm update -D
npm start
  • http://localhost:1234/url-timer/react (React 版)または http://localhost:1234/url-timer/vue (Vue 版)にアクセスすると以下のようなフォームが表示されます

f:id:ikasat:20181219114411p:plain

  • 時刻を ISO 8601 形式または UNIX time で入力すると画面遷移が起こり、その時刻までの残り時間(またはその時刻からの経過時間)をリアルタイム表示します
    • 例えば 2019-01-01T00:00:00+09:00 と入力すると http://localhost:1234/url-timer/react/1546268400 に遷移します
    • 経過時間は ISO 8601 形式で表示されます

f:id:ikasat:20181219114500p:plain

  • この画面に遷移した時点で Notifications API による通知の許可を得ようとします
  • 許可を得られた場合は指定時刻に通知を送信します
  • 以下の動作を 2 つのライブラリでどう実装するかが鍵になります
    • フォームに文字列を入力して <button> を押すと画面遷移する(form との連携)
    • URL に従って表示する Component を変える(Routing)
    • Component がマウントされたらタイマーをスタートし、アンマウントされたらストップする(ライフサイクル処理)
    • 残り時間・経過時間の表示を一定時間おきに更新する(状態管理・更新検知)
    • Notifications API による通知の許可を得る(非同期処理)

解説

import

React
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import * as Redux from 'redux'
import * as ReactRedux from 'react-redux'
import * as ReactRouter from 'react-router'
import * as ReactRouterDOM from 'react-router-dom'
import * as ConnectedReactRouter from 'connected-react-router'
import * as TypeScriptFSA from 'typescript-fsa'
import * as TypeScriptFSAReducers from 'typescript-fsa-reducers'
import * as Recompose from 'recompose'
import * as Formik from 'formik'
import * as History from 'history'
import * as luxon from 'luxon'

前回に引き続き、今回もいわゆる qualified import の形で書いています。

Vue
import Vue from 'vue'
import VueRouter, { Route } from 'vue-router'
import * as Vuex from 'vuex'
import * as VuexRouterSync from 'vuex-router-sync'
import * as luxon from 'luxon'

Vue.use(VueRouter)
Vue.use(Vuex)

vue-router, vuex のようなプラグインは(new Vue する前に)Vue.use(...) する必要があります。

ユーティリティ

共通
const baseURLPath = '/url-timer/react/'  // Vue 版では '/url-timer/vue/'

const convertStringToTimestamp = (s: string) =>
  s.match(/^\d+$/) != null ? +s * 1000 : luxon.DateTime.fromISO(s).toMillis()
const convertTimestampToString = (ts: number) => luxon.DateTime.fromMillis(ts).toISO()
const convertDurationToString = (d: number) =>
  luxon.Duration.fromMillis(Math.abs(d))
    .shiftTo('years', 'months', 'days', 'minutes', 'hours', 'seconds')
    .toISO()
const getNow = () => Math.floor(Date.now() / 1000) * 1000

後で使う定数やユーティリティ関数をここで定義しています。

States

状態管理ライブラリで管理する State の型とその初期値を定義します。

共通
type TimerState = {
  nowTimestamp: number
  notificationEnabled?: boolean
  notificationPermission?: NotificationPermission
}
const initialTimerState: TimerState = {
  nowTimestamp: getNow()
}

React, Vue で共通の部分です。

React
type State = {
  timer: TimerState
  router: ConnectedReactRouter.RouterState
}

URL の変化を Redux に反映させるために connected-react-router を使います。 ここでは型定義のみ行っています。

Vue
type State = {
  timer: TimerState
  route: Route
}

URL の変化を Vuex に反映させるために vuex-router-sync を使います。 Route 型は vue-router で定義されています。

Modules

React
const actionCreator = TypeScriptFSA.actionCreatorFactory('timer')
const setNowTimestamp = actionCreator<number>('SET_NOW_TIMESTAMP')
const setNotificationEnabled = actionCreator<boolean>('SET_NOTIFICATION_ENABLED')
const requestNotificationPermission = actionCreator.async<undefined, NotificationPermission>(
  'REQUEST_NOTIFICATION_PERMISSION'
)

const timerReducer = TypeScriptFSAReducers.reducerWithInitialState(initialTimerState)
  .case(setNowTimestamp, (state, payload) => ({ ...state, nowTimestamp: payload }))
  .case(setNotificationEnabled, (state, payload) => ({ ...state, notificationEnabled: payload }))
  .case(requestNotificationPermission.done, (state, payload) => ({
    ...state,
    notificationPermission: payload.result
  }))
  .build()

const doRequestNotificationPermission = async (dispatch: Redux.Dispatch) => {
  const perm = await Notification.requestPermission()
  dispatch(requestNotificationPermission.done({ result: perm }))
}

const createRootReducer = (history: History.History) =>
  Redux.combineReducers({
    router: ConnectedReactRouter.connectRouter(history),
    timer: timerReducer
  })

Redux のデータフローは View → Action → Middleware → Reducer → Store → View → … という流れになっています。 ここでは Vue + Vuex の書き方に合わせて Action と Reducer を一緒に説明していますが、 React + Redux においても Action と Reducer を Module として 1 ファイルにまとめる ducks というパターンがあります 1 。 前回に引き続き、Action と Reducer に手軽に型を付けるために typescript-fsa と typescript-fsa-reducer を使用しています。

Redux において非同期処理をどこに書くかという問題については議論がありますが、今回は Vue での書き方に合わせて Redux.Dispatch を受け取る doRequestNotificationPermission を async 関数としてここに定義しています。 この関数は後々 Container から呼ばれます。

Vue
const setNowTimestamp = 'SET_NOW_TIMESTAMP'
const setNotificationEnabled = 'SET_NOTIFICATION_ENABLED'
const setNotificationPermission = 'SET_NOTIFICATION_PERMISSION'
const requestNotificationPermission = 'REQUEST_NOTIFICATION_PERMISSION'

const timerModule: Vuex.Module<TimerState, State> = {
  state: initialTimerState,
  mutations: {
    [setNowTimestamp]: (state, payload: number) => {
      state.nowTimestamp = payload
    },
    [setNotificationEnabled]: (state, payload: boolean) => {
      state.notificationEnabled = payload
    },
    [setNotificationPermission]: (state, payload: NotificationPermission) => {
      state.notificationPermisson = payload
    }
  },
  actions: {
    [requestNotificationPermission]: async ({ commit }) => {
      const perm = await Notification.requestPermission()
      commit(setNotificationPermission, perm)
    }
  }
}

Vuex のデータフローは Component → Action → Mutation → State → Component → ... という流れになっており、 mutations に同期処理、actions に非同期処理を書くことができます。 Component から直接 Mutation を発行することもできます。

なお、この書き方では Mutation / Action 名と payload の型が紐づかないので、commit / dispatch する時に payload の型を誤る可能性があります。 これに対応するには vuex-type-helper などのサードパーティ製 2 ライブラリが必要です。

Store

React
const browserHistory = History.createBrowserHistory({ basename: baseURLPath })

// Redux Devtools
declare global {
  interface Window {
    __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof Redux.compose
  }
}

const composeEnhancers =
  (process.env.NODE_ENV === 'development' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || Redux.compose

const store = Redux.createStore(
  createRootReducer(browserHistory),
  composeEnhancers(Redux.applyMiddleware(ConnectedReactRouter.routerMiddleware(browserHistory)))
)

connected-react-router でブラウザ履歴と Redux Store を接続します。 また、ブラウザで Redux DevTools Extension を使う場合ここで設定をする必要があります。

Vue
const store = new Vuex.Store<State>({
  modules: {
    timer: timerModule
  }
})

Vuex Store を生成します。 なお、vuex-router-sync による store と router の繋ぎこみは router が定義された後で行います。 ちなみに vue-devtools は標準で Vuex にも対応しています。

TimerForm component

f:id:ikasat:20181219114411p:plain
TimerForm component

React
interface TimerFormProps {}

const TimerFormComponent = ({  }: TimerFormProps) => (
  <Formik.Form>
    <ul style={{ listStyleType: 'none' }}>
      <li>
        <Formik.Field type="text" name="targetTimeString" style={{ width: 300 }} />
      </li>
      <li>
        <button type="submit" style={{ width: 300 }}>
          Create Timer
        </button>
      </li>
    </ul>
  </Formik.Form>
)

type TimerFormData = {
  targetTimeString: string // form から取得するパラメータ (formik)
}
type TimerFormDispatchProps = {
  dispatch: Redux.Dispatch
}
type TimerFormMergedProps = TimerFormDispatchProps
type TimerFormInjectedFormikProps = Formik.InjectedFormikProps<TimerFormMergedProps, TimerFormData>
type TimerFormAllProps = TimerFormMergedProps & TimerFormInjectedFormikProps

const TimerFormContainer = Recompose.compose<TimerFormAllProps, {}>(
  ReactRedux.connect<{}, TimerFormDispatchProps, {}, State>(
    _state => ({}),
    dispatch => ({ dispatch })
  ),
  Formik.withFormik<TimerFormMergedProps, TimerFormData>({
    mapPropsToValues: _props => ({ targetTimeString: convertTimestampToString(getNow()) }),
    handleSubmit: (formData, { props: { dispatch } }) => {
      const unixTime = Math.floor(convertStringToTimestamp(formData.targetTimeString) / 1000)
      if (!isNaN(unixTime)) {
        dispatch(ConnectedReactRouter.push(`/${unixTime}`))
      }
    }
  })
)(TimerFormComponent)

TimerFormComponent は props を受け取り render を行うだけの Presentational Component で、 TimerFormContainer は Redux と接続される Container Component です。

Recompose.compose する順番は「後に書いたものほど内側の Component であり、外の Component から渡される props を使うことができる」ということを意識して決めましょう。 ここでは ReactRedux.connect で得られる dispatchFormik.withFormikhandleSubmit で使いたいのでこの順番にしています。 なお Recompose.compose のジェネリクス引数は <TInner, TOuter> で、 ここでは TInner に Presentational Component に渡す props (TimerViewAllProps) 、TOuter に Container Component が受け取る props ({}) を指定しています 3 。 上記コードの TimerFormPropsTimerFormAllProps は実際には一致するはずですが、Presentational Component の型が Container Component 側で定義される型に依存してほしくなかったために前者を interface として別に定義しています。

Formik.withFormikmapToPropsToValues で初期値を設定し、handleSubmit で submit 時の挙動を設定しています。 Presentational Component 側では <form> <input> の代わりに <Formik.Form>, <Formik.Field> Component を使います 4

Vue
const TimerForm = Vue.extend({
  template: `
    <form @submit.prevent="onSubmit">
      <ul style="list-style-type: none;">
        <li>
          <input type="text" v-model="targetTimeString" style="width: 300px;" />
        </li>
        <li>
          <button style="width: 300px;">Create Timer</button>
        </li>
      </ul>
    </form>
  `,
  data: () => ({
    targetTimeString: convertTimestampToString(getNow())
  }),
  methods: {
    onSubmit: function() {
      const unixTime = Math.floor(convertStringToTimestamp(this.targetTimeString) / 1000)
      if (!isNaN(unixTime)) {
        this.$router.push(`/${unixTime}`)
      }
    }
  }
})

Vue でも React と同様 JSX を使うこともできますが template に文字列として書くのが推奨された方法です(もちろん単一ファイルコンポーネントを使うのが最も推奨されています)。 data にバインドしたい値を定義し、templatev-model で値の双方向バインディングを行っています。 @submit.prevent="onSubmit" で submit 時に defaultPrevent しつつ methods に定義された onSubmit を呼んでいます。

TimerView component

f:id:ikasat:20181219114500p:plain
TimerView component

メインのビューなのでやや長いですが、React, Vue ともに以下の順で記述されています。

  • render される JSX / template
  • Store の state から取得する値とそれから算出される値
  • Component 固有の state として持つ値
  • ライフサイクル関連メソッド(Component が mount / unmount されたらタイマーを起動・解除する)
  • 起動したタイマーから定期的に呼ばれるハンドラ
React
interface TimerViewProps {
  duration: number
  targetTimeString: string
}

const TimerViewComponent = ({ duration, targetTimeString }: TimerViewProps) => (
  <div style={{ textAlign: 'center' }}>
    <h1>
      {convertDurationToString(duration)}
      <span style={{ fontSize: '80%' }}>{duration >= 0 ? ' (left)' : ' (ago)'}</span>
    </h1>
    <h2>{targetTimeString}</h2>
    <div style={{ marginTop: 5 }}>
      <ReactRouterDOM.Link to="/">Reset</ReactRouterDOM.Link>
    </div>
  </div>
)

Presentational Component です。

type TimerViewPathParams = {
  targetTimeString: string // URL (Path) から取得するパラメータ (react-router)
}
type TimerViewInjectedRouterProps = ReactRouter.RouteComponentProps<TimerViewPathParams>
type TimerViewOwnProps = TimerViewInjectedRouterProps
type TimerViewStateProps = {
  nowTimestamp: number
  notificationEnabled?: boolean
  notificationPermission?: NotificationPermission
}
type TimerViewDispatchProps = {
  dispatch: Redux.Dispatch
}
type TimerViewComputedProps = {
  targetTimestamp: number
  targetTimeString: string
  duration: number
}
type TimerViewMergedProps = TimerViewOwnProps & TimerViewStateProps & TimerViewDispatchProps & TimerViewComputedProps
type TimerViewAllProps = TimerViewMergedProps

Container Component で使う型です。分かりやすさのためにあらかじめきちんと定義しておきます。

const TimerViewContainer = Recompose.compose<TimerViewAllProps, {}>(
  ReactRouterDOM.withRouter,
  ReactRedux.connect<TimerViewStateProps, TimerViewDispatchProps, TimerViewOwnProps, TimerViewMergedProps, State>(
    ({ timer: { nowTimestamp, notificationEnabled, notificationPermission } }) => ({
      nowTimestamp,
      notificationEnabled,
      notificationPermission
    }),
    dispatch => ({ dispatch }),
    (stateProps, { dispatch }, ownProps) => {
      const { nowTimestamp, notificationEnabled, notificationPermission } = stateProps
      const targetTimestamp = convertStringToTimestamp(ownProps.match.params.targetTimeString)
      const targetTimeString = convertTimestampToString(targetTimestamp)
      const duration = targetTimestamp - nowTimestamp
      if (notificationEnabled && nowTimestamp >= targetTimestamp) {
        if (notificationPermission === 'granted') {
          const _notif = new Notification(targetTimeString)
        }
        dispatch(setNotificationEnabled(false))
      }
      return {
        ...stateProps,
        dispatch,
        ...ownProps,
        targetTimestamp,
        targetTimeString,
        duration
      }
    }
  )
)(
...

Container Component (前半)です。 Router から ReactRedux.connectmapStateToProps, mapDispatchToProps, mergeProps を取る 3 引数のものを使い、 mergeProps で Vue の算出プロパティ (computed) 相当の props を計算しています。 この書き方の場合、mapStateToProps の結果 (nowTimestamp, notificationEnabled, notificationPermission) のいずれかに変化があった時にのみ mergeProps が呼ばれ、 mergeProps の結果が変化した場合にのみ Component が再レンダリングされます。

...
  class extends React.Component<TimerViewAllProps> {
    timerId?: number

    componentDidMount() {
      const { dispatch, targetTimestamp } = this.props
      const onTick = () => dispatch(setNowTimestamp(getNow()))
      this.timerId = window.setInterval(onTick, 500)
      onTick()
      dispatch(setNotificationEnabled(targetTimestamp > getNow()))
      doRequestNotificationPermission(dispatch)
    }

    componentWillUnmount() {
      clearInterval(this.timerId)
    }

    render() {
      return <TimerViewComponent {...this.props} />
    }
  }
)

Container Component (後半)です。 Component 固有のプロパティやライフサイクル関連メソッドを持ちたいため Class Component としています。 ここは Recompose.withStateRecompose.lifecycle を使って Higher-order Component (HoC) として書くこともできます。 また、React v16.7 の alpha 版では React Hooks の useEffect を使って書くこともできます(「備考」にて後述)。

Vue
type TimerViewPathParams = {
  targetTimeString: string // URL (Path) から取得するパラメータ
}

const TimerView = Vue.extend({
  template: `
    <div style="text-align: center">
      <h1>
        {{ durationString }}
        <span style="font-size: 80%;" v-if="duration >= 0">(left)</span>
        <span style="font-size: 80%;" v-else>(ago)</span>
      </h1>
      <h2>{{ targetTimeString }}</h2>
      <div style="margin-top: 5px;">
        <router-link to="/">Reset</router-link>
      </div>
    </div>
  `,
  computed: {
    storeState: function(): State {
      return this.$store.state
    },
    routeParams() {
      return this.$route.params as TimerViewPathParams
    },
    nowTimestamp() {
      return this.storeState.timer.nowTimestamp
    },
    targetTimestamp() {
      return convertStringToTimestamp(this.routeParams.targetTimeString)
    },
    targetTimeString() {
      return convertTimestampToString(this.targetTimestamp)
    },
    duration() {
      return this.targetTimestamp - this.nowTimestamp
    },
    durationString() {
      return convertDurationToString(this.duration)
    }
  },
  watch: {
    nowTimestamp(newNowTimestamp: number) {
      const { notificationEnabled, notificationPermisson } = this.storeState.timer
      if (notificationEnabled && newNowTimestamp >= this.targetTimestamp) {
        if (notificationPermisson === 'granted') {
          const _notif = new Notification(this.targetTimeString)
        }
        this.$store.commit(setNotificationEnabled, false)
      }
    }
  },
  ...

前半です。templatecomputed(算出プロパティ)を定義しています。 また watchnowTimestamp が変化した場合にのみ特定の処理を実行しています。

後段で Store と Router を親コンポーネントに渡すことで子コンポーネントにもそれが注入され this.$store, this.$route として使うことができます。 これには型を自動では付けられないので手動でどうにかする必要があります。

  ...
  data: () => ({
    timerId: undefined as number | undefined
  }),
  mounted() {
    const onTick = () => this.$store.commit(setNowTimestamp, getNow())
    this.timerId = window.setInterval(onTick, 500)
    onTick()
    this.$store.commit(setNotificationEnabled, this.targetTimestamp > getNow())
    this.$store.dispatch(requestNotificationPermission)
  },
  beforeDestroy() {
    clearInterval(this.timerId)
  }
})

後半です。ライフサイクルメソッドとハンドラを定義しています。 this.$store.commit で Mutation、this.$store.dispatch で Action を発行しています。

Router

React
const App = () => (
  <ReactRedux.Provider store={store}>
    <ConnectedReactRouter.ConnectedRouter history={browserHistory}>
      <ReactRouter.Switch>
        <ReactRouter.Route path="/" exact={true} component={TimerFormContainer} />
        <ReactRouter.Route path="/:targetTimeString" component={TimerViewContainer} />
      </ReactRouter.Switch>
    </ConnectedReactRouter.ConnectedRouter>
  </ReactRedux.Provider>
)

ReactDOM.render(<App />, document.getElementById('app') as HTMLElement)

ReactRedux.connect を使うために store を与えた ReactRedux.Provider を最も外側の Component としています。 React では Routing も JSX として定義します。

Vue
const router = new VueRouter({
  base: baseURLPath,
  mode: 'history',
  routes: [{ path: '/', component: TimerForm }, { path: '/:targetTimeString', component: TimerView }]
})

VuexRouterSync.sync(store, router)

const App = Vue.extend({
  template: `<router-view></router-view>`,
  store,
  router
})

const _vm = new Vue({
  el: '#app',
  render: h => h(App)
})

Vue Router の mode は標準では hash となっており、# の後の文字列を変えることで Single Page Application を実現します。 modehistory とすることで History API を使った SPA となります。

書いた感想

筆者は React については 2015 年頃から(断続的にですが)触っていますが、Vue は今年になって初めて触りました。 React と Vue の歴史や根本的な共通点・差異については別の記事に譲り、ここでは今回実際に書いてみて(改めて)感じたことを書きます。

アプリケーション全体の構成はあまり変わらない

今回 React + Redux と Vue + Vuex 版のいずれも States, Modules (Actions + Reducers), Store, Components, Router の順になるよう実装しており、大まかな構造に違いはありません。 Vuex が Redux の影響を受けているのと、今回私が React 版と Vue 版の対応が取れるようにある程度意識して記事を書いているので当たり前ではありますが……。

エコシステムについて

React 本体は Flux アーキテクチャ(あるいは MVC)の View のみを担当するライブラリであり、実用的なアプリケーションを作るには周辺のライブラリを選定して組み合わせて使う必要があります。 特に Redux 用の非同期処理 Middleware(redux-thunk, redux-promise, redux-saga, redux-observable)、Router (connected-react-router) 、form との統合 (formik, redux-form) に何を使うか(あるいは使わないか 5 )はよく議論の的になります。

今回は単純なアプリケーションであり、比較のために Vue での書き方に寄せたかったため非同期処理 Middleware を使っていません 6 。 connected-react-router については前回記事からバージョンを上げたら v4 → v5 で破壊的変更があり、また最新の v6 には問題があったため 1 つバージョンを落として使っています。 form との統合については前回は redux-form を使いましたが、今回は後発の formik を使ってみることにしました。 このように React + Redux を使う際には場合に応じてライブラリを使い分け、破壊的変更に対応し、新しいライブラリの人気が上昇してきたら適宜ウォッチしていかなければなりません。 もちろんむやみに最新のものを使うのではなく安定を取っていく必要もありますし、時には自前で実装した方がよいこともあります。 手間はかかりますがその分開発者が介入できる部分は多く自由度は高いと言えます 7 。 ライブラリのモジュール性が高い分それを挿げ替える際に段階的な移行や併用を行っていける可能性もあります。 また、関連パッケージの個数については npm において keywords に react を含むものは 35,564 件vue を含むものは 10,085 件 と前者の方が多いです。

一方 Vue コミュニティは公式に Vuex, Vue Router を提供していますし、Vuex は非同期処理に初めから対応しています。form との統合も Vue 本体の機能だけでほぼ大丈夫です。 よって、どのライブラリを使うかを考えなければならないような場面は React の場合と比較すると少ないです。 Redux は React に依存しないプロジェクトですが、Vuex は Vue に依存しており、より深い層での統合が可能になっています。 vue-devtools も公式のプロジェクトですし、初めから Vuex に対応しています(React の場合 React DevTools と Redux DevTools の両方が必要)。 その分 Vue というフレームワークにロックインされるため、バージョンアップ時に一度に多数の変更を行わなければならない可能性もあります。 なお 2019 年には v2 から v3 へのバージョンアップが予定されており、今後も Vue コミュニティの動きは注視しておく必要があります。

個人的には日本語リファレンスを公式に用意してくれているのが Vue の一番嬉しい点だったりします。

状態の更新検知の仕組みについて

React + Redux を使う場合、State の変更検知は基本的に「State Object が(=== 比較して)同一かどうか」で行われます。 Class Component 中では this.state.foo = 42 は禁物で this.setState({ foo: 42 }) する必要があり、 Redux の Reducer((state, action) => state)では新しい State を新たに作って返さなければなりません。 よって必然的に immutable スタイルの書き方になり、オブジェクトのプロパティを直接変更することはほぼなくなります。 この変更検知の仕組みは(ただの === なので)非常にシンプルですが、その代わりに過剰なレンダリングが発生する可能性もあります。 そのためコンポーネントのレンダリングを抑制する仕組みとして react-redux の connect 関数ではメモ化が行われていますし、 Redux コミュニティはメモ化を支援する reselect ライブラリを提供しています。 React v16.6 からは React 本体に memo というそのままの名前の関数が導入され、React Hooks の多くは処理すべきかどうかを判定するためのキーを取ります。useMemo という Hooks API もあります。 結論として、React + Redux を使う場合には state を新たに作って返す書き方を覚える必要があり、必要とあらばレンダリングを抑制するこれらの仕組みについてある程度理解しておく必要があります 8

一方 Vue では Object.defineProperty でハックされたオブジェクトのゲッタ・セッタを多用します。 this.foo を使ったり this.foo = 42 と代入したりするだけで様々な処理が走り、プロパティ間の依存関係を追いつつ最終的にレンダリングすべきかどうかを自動で判定してくれます 9 。 Vue 自体はこのような仕組みを持っているのですが、人間がソースコードを見て依存関係を追いレンダリングが行われるかを判定できるのかどうかは別問題です。 また、より高度なレンダリングの制御を行うにはマイナーな機能を使った工夫が必要そうです(私は入門して日が浅いのでまだ勘所を掴めていません……)。 なお Vue v3 では observer の実装が Proxy を使ったものに変更されるようです 10

いずれのライブラリを使う場合でもパフォーマンスが問題にならないような局面ではそこまで気にせずともよいと思われます(問題となってしまった時に詰んでしまうのは避けたいですが)。

TypeScript との相性について

個人的なイメージとして Vue は型を付けにくそうなインターフェースをしているとかつて感じていたのですが、 2017 年 10 月の v2.5 のリリース以来 TypeScript 統合は劇的に改善されているようです。 ちなみに vue, vuex, vue-router 等の公式パッケージの TypeScript の型定義はパッケージに同梱されており、npm i -D @types/... する必要はありません。 公式で TypeScript の型定義をサポートしようとする意志が感じられますし、実際、Vue v3 においてはコードベースが TypeScript で書き直されるようです。

なお Vue 本体の型付けについては、TypeScript の比較的高度な型推論と、漸進的型付けの「内部でメタプログラミングを多用しようが結果的に入口と出口の型さえ合っていればよい」という特性を最大限生かした定義となっており、 「datacomputed 下に定義したプロパティが this からきちんと生えてくる」などというある種アクロバティックなものになっています 11 。 Vuex の型定義については Vue 本体のそれと比較すると最低限のものとなっており、 Action や Mutation にちゃんと型を付けようとするとサードパーティ製のライブラリを使うか独自のヘルパ関数を定義する必要があります。

React と Redux およびその周辺ライブラリの型定義については「堅実」という印象です 12 。 Vue と比較すると明示的に型を書かなければならない場面が多い気がしますが、逆に言うと多くの箇所に常識的な型をつけることが可能です。 ただし ReactRedux.connectReactRouterDOM.withRouter など Higher-order Component (HoC) が絡む部分はやや面倒なので適度に割り切っていく必要があります。 Action と Reducer については typescript-fsa(-reducer) というサードパーティのライブラリを使うのが手っ取り早いです。この記事でも前回に引き続き利用しています。

React と Vue に共通して言えるのですが、漸進的型付けはあくまで漸進的であり、推論に頼らず手で型を付けていかなければならない場面は多いです。 一部の API については誤った型のままなんとなく型検査が通ってしまったりする場面もあるので型推論に頼りきるのは禁物です。 特にジェネリクスの型引数を省略するとうまくいかない場面が多々あったため、「型引数か関数引数・戻り値の型のいずれかを省略できる」ような場合は型引数を優先して明示する方がよいと思われます。

備考

React Hooks を使う

React v16.7 の alpha 版では Hooks という新しい API を使うことができます。

Hooks によって Class Component や Higher-order Component (HoC) として書いていた部分を Functional Component で書き換えられるようになります。 一方、HoC の生成を手助けするライブラリである recompose については「バグフィックスや将来の React の変更に対する互換性維持は(おそらく)行うものの新機能は追加しない」という状態となりました。

しばらくは recompose を使い続けても問題はないとは思いますが、React Hooks を使った書き方を覚えておく必要が生じています。 今回の例を React Hooks の useEffect で書くと以下のようになります(抜粋)。

type TimerViewAllProps = TimerViewMergedProps

const TimerViewContainer = Recompose.compose<TimerViewAllProps, {}>(
...
)((props: TimerViewAllProps) => {
  React.useEffect(() => {
    const { dispatch, targetTimestamp } = props
    const onTick = () => dispatch(setNowTimestamp(getNow()))
    const timerId = window.setInterval(onTick, 500)
    onTick()
    dispatch(setNotificationEnabled(targetTimestamp > getNow()))
    doRequestNotificationPermission(dispatch)
    return () => clearInterval(timerId)
  }, [])
  return <TimerViewComponent {...props} />
})

React v16.7 と alpha 版について

React v16.7 については正式版が 2018/12/20 にリリースされていますが、これには React Hooks は含まれておらず別のバグ修正のためのマイナーバージョンアップとなっています。2018/12/25 現在 npm i react@next react-dom@next すると 16.7.0-alpha.2 が入り、こちらで React Hooks を使うことができます。

おわりに

今回は React + Redux と Vue + Vuex で同じアプリケーションを書きその共通点と差異について探りました。 数百行のコードですが実際に書いてみるとやはり得られるものは多々ありました。 2 つのライブラリには大きな違いもありますが最大公約数的な部分もあり、それを知っておけば今の・将来のフロントエンド技術を理解する上での手助けになると思います。

タイマーアプリとしては「相対時間指定に対応する(「3分後」と指定したら3分後に通知を送る)」「通知に任意の文字列を表示する」「<title> に残り時間を表示する」 「スヌーズする」「ちゃんとしたデザインにする」などといった機能を実装すればちょっと実用的になるのではと思います。 これは読者への演習課題とします……。 あとは Jest を使ったテスト環境の構築や、React + Next.js と Vue + Nuxt.js の書き比べなんかをしてみてもよいかもしれません(わざわざ Server Side Rendering する意味はあまりないアプリケーションではあるのですが)。 React や Vue を使えば URL を使った一芸アプリケーションを比較的容易に作れるのでアイデアのある方は是非試してみてください。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。


  1. これを更に改善した re-ducks パターンもあります。

  2. Vue のコミッタの方のライブラリのようです。

  3. Recompose.compose の型定義を使うと一番外側の Component と一番内側の Component に渡すべき props の型しか検査されません。ちなみに Redux.compose はより一般的かつ複雑な型定義を持っていますがその分扱うのが困難です。いっそのこと関数合成として書かずに関数を普通に入れ子にして書いてしまった方がよいかも……。

  4. withFormik によって handleSubmit, handleChange が inject されるので <form>onSubmit<input>onChange にそれを指定してもよいです。

  5. 非同期処理は今回のように Component (ReactRedux.connect) 側で行うようにするなど。また将来的に React コアに Suspense という非同期処理機構が入ることが予定されています。

  6. 非同期処理 Middleware は個人的には Generator ベースの redux-saga を使っています。cokoa を使っている(いた)人にとっては馴染みやすい API だと思います。

  7. React そのものではなく React に非常に近い API を持つ preactInferno を使う選択肢すらあり得ます。

  8. ちなみに React で Vue に近いリアクティブ性を実現する状態管理ライブラリとしては MobX があります。

  9. このため「Date.now()Math.random()computed でそのまま使うと値がキャッシュされ続ける」「条件によってプロパティが使われたり使われなかったりすると依存関係を追いきれない」といった特性があります。

  10. これにはプロパティの追加・削除が検出できるようになるというメリットがありますが、ECMAScript 2015 に対応していないブラウザ(具体的には IE 11)で動かすことはできなくなります。そのため Vue 公式は IE 11 でも動作する observer 実装も提供するようです。

  11. それゆえに「computed 下の関数の全てにメソッド省略記法を使うと型推論に失敗する(1 つでも省略記法でなければ成功する、TimerView を参照)」といった謎の挙動を踏んだりします(型推論の限界なのか型付けの不具合なのかは未調査)。

  12. react, react-dom パッケージは Flow という Facebook 製の別の型付き JavaScript で書かれています。