React(などを使ったSPAな)アプリでLIFFを実現するときに注意した方が良いかもしれないこと

2022.02.09

追記(2022/2/17): こちらも合わせて御覧ください。React+LIFFのサンプルコードがLINE公式から出てました | DevelopersIO


吉川@広島です。

LIFFアプリをReactで構築するにあたって、 liff.init()useEffect() 内で行うのではなく、アプリケーションのエントリポイントで

import React from 'react'
import ReactDOM from 'react-dom'
import liff from '@line/liff'

liff.init(/* 省略 */).then(() => {
  ReactDOM.render(/* 省略 */)
})

のように ReactDOM.render() より前に実行した方が良いのかもしれないという話をします。

環境

  • node 16.13.2
  • typescript 4.4.4
  • react 17.0.2
  • react-router-dom 6.2.1
  • @line/liff 2.18.1

アプリケーション例

react+react-routerを使ったSPAアプリを例に考えます。

  • エントリポイント: main.tsx
  • Rootコンポーネント: app.tsx
  • Pageコンポーネント: foo.tsx, bar.tsx

このようなファイル構成とします。

// main.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)
// app.tsx

import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Foo } from './foo'
import { Bar } from './bar'

const App: React.VFC = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/foo" element={<Foo />} />
        <Route path="/bar" element={<Bar />} />
      </Routes>
    </BrowserRouter>
  )
}

export default App
// foo.tsx

import React from 'react'
import { Link } from 'react-router-dom'
import liff from '@line/liff'

export const Foo: React.VFC = () => {
  React.useEffect(() => {
    liff.init({ liffId: 'xxxxxxxxxxxxxx' })
  }, [])

  return (
    <div>
      <ul>
        <li>[Now]Foo Page</li>
        <li>
          <Link to="/bar">Bar Page</Link>
        </li>
      </ul>
    </div>
  )
}
// bar.tsx

import React from 'react'
import { Link } from 'react-router-dom'
import liff from '@line/liff'

export const Bar: React.VFC = () => {
  React.useEffect(() => {
    liff.init({ liffId: 'xxxxxxxxxxxxxx' })
  }, [])

  return (
    <div>
      <ul>
        <li>
          <Link to="/foo">Foo Page</Link>
        </li>
        <li>[Now]Bar Page</li>
      </ul>
    </div>
  )
}

Foo、Barコンポーネントの React.useEffect() 内で liff.init() するようにしています。

開発用サーバを起動して /foo を開いてみます。下記のような表示です。

liff.init()の冪等性

最初に /foo を表示した時のDevTools Elementsです。

liff.init() した際にこのようなscriptタグが追加されるようです。

そして、「Foo Page」「Bar Page」リンクを複数回押してページを行き来してみます。その結果、Elementsは下のようになります。

重複したscriptタグが5つ並んでいます。このように、 liff.init() が実行された回数分scriptタグが挿入されてしまうようです。

このことから、 liff.init() の処理は冪等性がないのではないかと考えています。 liff.init() が他にどのような処理をしているかについては、ソースコードが公開されていないためドキュメント以上のことはわからず、現時点ではscriptタグが重複する以外の影響は見えていないのですが、冪等性がないとすれば何度も実行することで意図しない挙動につながる可能性はあるかもしれないと思っています。

解決策

[その1] liff.init()の後にReactの描画を開始する

まず、シンプルに ReactDOM.render() の前に liff.init() することを考えます。個人的にはこちらが推奨です。

import React from 'react'
import ReactDOM from 'react-dom'
import liff from '@line/liff'

liff.init(/* 省略 */).then(() => {
  ReactDOM.render(/* 省略 */)
})

この方法は抵抗がある人もいるかもしれません。自分がそうだったのですが、なぜそう感じるのか考えてみたところ、Reactが先にあって、Reactアプリケーションの上でLIFF SDKを扱う という考え方だと useEffect() の中で liff.init() したくなるのかなと思いました。

これを逆にして、 LIFFが先にあって、LIFFアプリケーションの上でReactを扱う という考え方をすればこの実装を自然に受け入れることができる気がします。

[その2] RootコンポーネントのuseEffect()でliff.init()する

それでも useEffect() でやりたい、かつ liff.init() を複数回実行したくない場合は可能な限りRootに近いコンポーネントで useEffect() を実行するという方法が考えられます。上述の例でいうとAppコンポーネントですね。

// app.tsx

import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Foo } from './foo'
import { Bar } from './bar'

const App: React.VFC = () => {
  // よりRootに近いAppコンポーネントでliff.init()する
  React.useEffect(() => {
    liff.init({ liffId: 'xxxxxxxxxxxxxx' })
  }, [])

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/foo" element={<Foo />} />
        <Route path="/bar" element={<Bar />} />
      </Routes>
    </BrowserRouter>
  )
}

export default App

これであればPageを行き来しても liff.init() が複数回実行されることはなさそうです。

ただし、将来的にReactはuseEffect+空配列でも複数回実行される可能性を考慮することを開発者に求めていくようなので、この方法も推奨とは言えなさそうではあります。

React 18 alpha版発表まとめ

React 18 ではStrictModeに新たな挙動が追加されます。言い方を変えれば、React 18 では“正しい”コンポーネントのルールが追加されるということです。 具体的には、StrictMode下では「コンポーネントのマウント時に余計にuseEffectが発火する」という挙動が加えられます。特に、useEffect(() => { /* ... */ }, [])という形のエフェクトであっても複数回走る可能性が生じます。

これは useEffect() 内の処理は冪等にすべきということだと解釈しています。

その他考えたこと (※もしも話です)

もしLIFF SDKに初期化済かどうかがわかる liff.isInitialized() のような関数が生えていれば、

React.useEffect(() => {
  if (!liff.isInitialized()) {
    // 初期化されていない場合のみ初期化処理する
    liff.init({ liffId: 'xxxxxxxxxxxxxx' })
  }
}, [])

のように自分で冪等な処理を実装できるかもしれないと思いました。これをReactのCustom Hookに切り出しても良いかもしれません。

もしくは、初期化を打ち消すような関数 liff.uninit() があれば、

React.useEffect(() => {
  // 初期化されていない場合のみ初期化処理する
  liff.init({ liffId: 'xxxxxxxxxxxxxx' })
  return () => {
    liff.uninit()
  }
}, [])

のようにクリーンアップすることで一貫性を保てるかもしれないと思ったりしました。

まとめ

liff.init() は冪等でない可能性があるため、 useEffect() で使うには適さないかもしれないという話でした。

推測多めなので若干歯切れが悪くなってしまっているのですが、参考になれば幸いです。

参考