🙆

ReactからVueコンポーネントをブリッジして使う方法

2022/07/31に公開

始めに

Vue.jsからReactに乗り換えよう!そんなふうに思ったときに、規模が小さいなら一気に全部差し替えることができるかもしれませんが、大きくなるとそれは難しくなると思います。段階的にやる方法の1つとして1ページずつReactに差し替えていくことが考えられますが、そこで使われているVueコンポーネントが他のページと共通で使われていて迂闊に差し替えることができない・・・なんてケースもあると思います。
最初の移行コストを下げるために、そもそもVueコンポーネントをブリッジさせて表面上だけReactで動かすみたいなことができないかなと色々検証していて、ある程度動くようになったので記事にまとめたいと思います。今回は移行の話なのでVue2を対象にしています。

検証イメージ

下のGifアニメのような、Reactアプリの中でVue.jsで作ったカウンターコンポーネントを呼び出してデータやイベントを連携してリアクティブに動くようにします。

ReactからVueコンポーネントを呼んで使う

Vueコンポーネントの用意

まず始めに対象となるVueコンポーネントを用意したいと思います。

Vue.jsで作ったカウンターコンポーネント
<template>
  <div class="vue-counter">
    <div>Vue Counter</div>
    <div class="counter">
      <button class="counter__button" @click="add(-1)">-</button>
      <div class="counter__count">{{ $props.count }}</div>
      <button class="counter__button" @click="add(1)">+</button>
    </div>
  </div>
</template>

<script>
export default Vue.extend({
  props: {
    count: Number,
  },
  methods: {
    add(value) {
      this.$emit('update:count', this.$props.count + value);
    },
  },
});
</script>

Vueコンポーネントをマウントさせる用のReactコンポーネントを用意する

このVueコンポーネントをReactで使えるように、ラップしたコンポーネントをReactで作ります。
大まかに以下のことをしています。

  1. const app = new Vue()みたいに書いてVueインスタンスを作る
  2. ReactのDOMに対してappをmountさせる
  3. Reactからの変更/Vue.jsからの変更イベントの橋渡しをする
    3.1. Reactで検知した変更をapp.$dataに注入させる
    3.2. Vue.jsからのイベントをon~のメソッドに変換してReactで拾えるようにする
  4. 破棄するときはキチンとunmountして後処理をする
VueカウンターコンポーネントをブリッジするReactコンポーネント
const BridgeVueCounter = (props) => {
  // 3.2. Vue.jsからのイベントを`on~`のメソッドに変換してReactで拾えるようにする
  // Vueコンポーネントに渡すメソッドは固定したいが、Reactのpropsではメソッドが変わる可能性があるので、useRefを使って参照コピーのメソッドを呼び出すようにする
  const onChangeCountRef = useRef(props.onChangeCount);
  const onChangeCount = useCallback((newCount) => {
    onChangeCountRef.current(newCount);
  }, []);
  
  const elRootRef = useRef(null);
  // 1. `const app = new Vue()`みたいに書いてVueインスタンスを作る
  const vueCounterApp = useMemo(() => {
    const app = new Vue({
      components: { VueCounter },
      template: `
        <VueCounter
          :count="$data.count"
          @update:count="onChangeCount"
        />
      `,
      data() {
        return {
          count: props.count,
        };
      },
      methods: {
        onChangeCount,
      },
    });
    return app;
  }, []);
  
  // 3.1. Reactで検知した変更をapp.$dataに注入させる
  useEffect(() => {
    vueCounterApp.$data.count = props.count;
  }, [props.count]);
  
  useEffect(() => {
    onChangeCountRef.current = props.onChangeCount;
  }, [props.onChangeCount]);
  
  useEffect(() => {
    // 2. ReactのDOMに対してappをmountさせる
    vueCounterApp.$mount();
    elRootRef.current.appendChild(vueCounterApp.$el);
    
    // 4. 破棄するときはキチンとunmountして後処理をする
    return () => {
      vueCounterApp.$destroy();
    };
  }, []);

  return (<div ref={elRootRef} />);
}
BridgeVueCounter.propTypes = {
  count: PropTypes.number.isRequired,
  onChangeCount: PropTypes.func.isRequired,
}

実際に呼び出してみて動作確認

ブリッジされたReactのカウンターコンポーネントができたのであとは普通にReactからこのコンポーネントを呼んで使うだけです。

ブリッジしたReactカウンターコンポーネントを使用する
const ReactApp = () => {
  const [isShowCounter, setIsShowCounter] = useState(true);
  const [count, setCount] = useState(0);
  
  const onChangeCount = useCallback((newCount) => {
    // メソッドを変えてもちゃんと最新のメソッドが呼ばれているかの確認用
    console.log('localCount:', count);
    setCount(newCount);
  }, [count]);

  return (
    <div>
      <div>React App</div>
      <label>
        <input
          type="checkbox"
          checked={isShowCounter}
          onChange={(event) => { setIsShowCounter(event.currentTarget.checked) }}
        />
        Show Counter
      </label>
      <div>count: { count }</div>
      {isShowCounter ? (
        <BridgeVueCounter
          count={count}
          onChangeCount={onChangeCount}
        />
      ) : null}
    </div>
  )
}

const elReactEntry = document.getElementById('react-app');
const root = ReactDOM.createRoot(elReactEntry);
root.render(<ReactApp />);

サンプルコード

サンプルはこちらに書きましたので、詳細を見たい方はこちらをご参照ください。

おまけ

逆にVue.jsからReactコンポーネントを呼んで使う

逆にReactコンポーネントをVue.jsから使うにはどうなるのか検証して見ましたが、こんな感じに書くと動きました。Reactの場合はrenderするたびに差分更新するらしいので$propsをwatchして更新のたびにrenderするだけなので比較的簡単に実装できました。ファイル内にReactのJSXが入ることと、templateをほぼ書かないことからJSXファイルとして定義すると良さそうでした。

ReactカウンターコンポーネントをブリッジするVueコンポーネント
const BridgeReactCounter = Vue.extend({
  template: '<div />',
  props: {
    count: Number,
  },
  mounted() {
    this.reactRoot = ReactDOM.createRoot(this.$el);
    this.renderReact();
  },
  watch: {
    '$props': {
      handler() {
        this.renderReact();
      },
      deep: true,
    },
  },
  methods: {
    renderReact() {
      this.reactRoot.render(
        <ReactCounter
          count={this.$props.count}
          onChangeCount={this.onChangeCount}
        />
      );
    },
    onChangeCount(newCount) {
      this.$emit('update:count', newCount);
    },
  },
  beforeDestroy() {
    this.reactRoot && this.reactRoot.unmount();
  }
});

サンプルは以下になりますので、興味ある方は見てください。

終わりに

以上がReactからVueコンポーネントをブリッジして使う方法でした。分かりやすさ優先で1つ1つwatchしたりメソッドを定義してコールバックを中継させていますが、もっと汎用性を上げてラップするだけでブリッジさせることもできそうな感じがしました。実際に動くコードができたら追記するかもしれません。
Reactに乗り換えたくてもVue.js特有の実装があったり、Vue.jsにしかないライブラリを使っていたりで簡単には移行できず結局ズルズルと引きずってしまうことがあると思います。そういった人に今回のようなブリッジを使って大枠だけReactに乗り換えることもできるということを知って、移行判断の一つのキッカケになれると幸いです。

Discussion