Next.jsでIonic Frameworkを使う

この記事は、Next.js Advent Calendar 2020の8日目です。

Ionic Framework(@ionic/react)をNext.jsで使おうとすると、だいたいこういうエラーに遭遇します。

% yarn build 
yarn run v1.22.5
$ yarn --cwd packages/web-app build
$ next build
info  - Creating an optimized production build  
info  - Compiled successfully

> Build error occurred
ReferenceError: HTMLElement is not defined

なにがおきているのか

IonicはWebComponent(Stencil)で作られたフレームワークです。そのため、各タグを利用するためには、defineCustomElements()を実行してカスタムエレメントを登録する必要があります。

そして@ionic/reactでは、この事前準備系はimportするだけで内部的に実行されるように作られています。便利ですね。

しかしこの内部的によしなにする部分がNext.jsやGatsbyで引っかかります。というのも、defineCustomElementsの引数はwindowだからです。これはNode.jsでは存在しないものですので、SSRまたはSSGを実行すると、Not Foundエラーが発生します。

コードを見る限り、typeof window !== 'undefined'のチェックはあるのですが、どうも動かないのでどうしたものかなというところです。Issueなどを調べるといろいろと各自試行錯誤されている様子が窺えます。

個人的解決方法: 思い切って@ionic/coreをつかう

現在の本サイトがNext.js + Ionicなのですが、実はここでは@ionic/reactを使っていません。

Next.jsのサンプルリポジトリを見ると、Stencilを使ったversionが用意されていました。
https://github.com/vercel/next.js/tree/canary/examples/with-stencil

これをみるに、Web Componentが動かないのではなく、defineCustomElementの実行場所次第でこけることがあるという理解が良さそうです。

ということで、Ionicの本体でもある@ionic/coreそのものを使う形で実装します。

Step by step

Step1: Create Next.js application

# Setup project
$ npx create-next-app nextjs-ionic

# Go to web root
$ cd nextjs-ionic

# Put tsconfig.json
$ touch tsconfig.json

# Install TypeScript packages
$ yarn add -D typescript @types/react @types/node

Step2: Install Ionic components

@ionic/coreを使う場合、一緒にioniconsもインストールしましょう。

$ yarn add @ionic/core ionicons

Step3: Define Custom Elements Types

JSXでIonicのタグを使う場合、JSXの型情報に追加登録してやる必要があります。

pages/_app.tsx

import { JSX as LocalJSX} from '@ionic/core'
import {JSX as IoniconsJSX} from 'ionicons'
import { HTMLAttributes, ReactText } from 'react'

type ToReact<T> = {
  [P in keyof T]?: T[P] & Omit<HTMLAttributes<Element>, 'className'> & {
    class?: string;
    key?: ReactText;
  }
}

declare global {
  export namespace JSX {
    interface IntrinsicElements extends ToReact<LocalJSX.IntrinsicElements & IoniconsJSX.IntrinsicElements> {}
  }
}

Step4: Define Custom Element and import CSS

続いでCSSとloaderを実装します。defineCustomElementsを実行する必要がありますので、こちらはuseEffectを使って副作用側で処理しましょう。

pages/_app.tsx

import React, { useEffect } from 'react'
import { defineCustomElements as ionDefineCustomElements } from '@ionic/core/loader';
/* Core CSS required for Ionic components to work properly */
import '@ionic/core/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/core/css/normalize.css';
import '@ionic/core/css/structure.css';
import '@ionic/core/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/core/css/padding.css';
import '@ionic/core/css/float-elements.css';
import '@ionic/core/css/text-alignment.css';
import '@ionic/core/css/text-transformation.css';
import '@ionic/core/css/flex-utils.css';
import '@ionic/core/css/display.css';

function MyApp({ Component, pageProps }) {
  useEffect(() => {
    ionDefineCustomElements(window)
  })
  return <p>Hello</p>
}

export default MyApp

Finally: Use Ionic component into the application

あとはIonic Angularライクにコードを書くだけです。

pages/home.tsx

import Image from 'next/image'

export default function Home() {
  return (
        <ion-refresher slot="fixed" closeDuration="10ms">
          <ion-refresher-content />
          <ion-card>
            <ion-card-header>
              <ion-card-subtitle>Card Subtitle</ion-card-subtitle>
              <ion-card-title>Card Title</ion-card-title>
            </ion-card-header>

            <ion-card-content>
            <Image
              src="./images"
              alt="Picture of the author"
              width={500}
              height={300}
            />
              Keep close to Nature's heart... and break clear away, once in awhile,
              and climb a mountain or spend a week in the woods. Wash your spirit clean.
            </ion-card-content>
          </ion-card>
        </ion-refresher>
  )
}

ioniconのSVGが表示されない場合は、webpack + リダイレクトで対応する

再現性がある問題なのかまで掘り下げれていないのですが、ioniconのアイコンSVGが描画されない時がありました。

SVGのパスが意図した通りにならない様子なので、自分のサイトの場合は、WebpackのCopyPluginを使ってコピーして、Netlifyのリダイレクトを使って調整しています。

const CopyPlugin = require('copy-webpack-plugin')
const path= require('path')

module.exports = {
...
  pageExtensions: ['ts', 'tsx'],       
    webpack: (config) => {       
      config.plugins.push(      
        new CopyPlugin({                       
          patterns: [{      
            from: path.join(__dirname, 'node_modules/ionicons/dist/ionicons/svg'),       
            to: path.join(__dirname, 'public/svg')   
          }]       
        })       
      )       
      return config         
    }          
  }

Netlify側はこんな感じ

[[redirects]]      
    from = "/:lang/:slug/svg/:filename"    
    to = "/svg/:filename"       
    status = 200       
                
[[redirects]]    
    from = "/:slug/svg/:filename"    
    to = "/svg/:filename"      
    status = 200

注意点など

割と力技的解決ですが、簡単なブログサイトを作る程度であれば問題なく動いています。いまのところ。

ただ、ionXXX系の挙動がちょっと怪しい気配があったり、@ionic/reactで未対応のものはやっぱり動かなかったりと、なかなか縛りプレイになることはご留意ください。

サンプル

Comment