thumbnail_gatsby_logo

gatsby-plugin-smoothscrollはiPhoneで動作せずreact-scrollに変更

公開日2020.10.07

iPhoneで動作しなかったので、gatsby-plugin-smoothscrollからreact-scrollへ変更しました。 目次もgatsby-transformer-remarkで生成されたものを止め、htmlをパースしてreact-scrollでスムーズスクロールさせるように。 ですが、そこで更に問題に直面することに……。 JavaScriptとnode.jsの動作の違いって難しいですね。

目次目次

gatsby-plugin-smoothscrollがiPhoneで動かない件

Netlifyで公開してから友人に見てもらった所、「トップへ戻るボタン」がiPhoneで正常に動作していないと報告が来ました。 Chromeのデベロッパーツールでも、私物のAndroidでも動作しているのに何故? という問題が自分一人では全く解決できず、手伝って貰い検証してみると、iPhoneの他にMacのSafariでも動作していないことが判明。

プラグインの頭にも書いてありますが、gatsby-plugin-smoothscrollは内部で「smoothscroll-polyfill」を使用しており、そちらの解説に

To date this has only been implemented in Chrome, Firefox and Opera.

// google翻訳 // 現在まで、これはChrome、Firefox、Operaでのみ実装されています。 https://www.npmjs.com/package/smoothscroll-polyfill

と記述があるので、ちゃんと調べれば分かることだったのですが、Pluginの使い方だけ読んで見落としていました。Woozy Face Emoji そう言えばiPhoneのブラウザはchromeだろうと中身はsafariですからね。 (シェアはあるのに一つだけ非対応なんてIEみたいな状態は困ります……)

対処としてReact Scrollを導入

今回はライブラリをreact-scrollに変更して対処することに。

インストールしてonClickに関数を指定するだけで簡単に導入できます。

npm i react-scroll
TSX
import { animateScroll } from 'react-scroll'

return (
  <div
    onClick={() => animateScroll.scrollToTop()}
    onKeyDown={() => animateScroll.scrollToTop()}
    role="button"
    tabIndex={0}
  >
    <SvgTriangle />
    <div>Top</div>
  </div>
)

重要なのはonClick={() => animateScroll.scrollToTop()}で、これを要素につければクリックするとトップへ戻る動作を実装できます。 他の項目はeslint先生が「やりなさい」と仰るので書いていますが、別になくても動きます

私はiPhoneを使っていないので、問題を発見してくれて修正方法も教えてくれた友人に感謝。

目次もReact-Scrollでスムーズスクロールにしよう

gatsbyのマークダウンパーサーであるgatsby-transformer-remarkには、markdonwの見出しから目次用のhtmlを自動生成してくれる機能があります。 これ、凄く便利なのですが、完全にhtmlのリストとして出力されるので、dangerouslySetInnerHTMLを使ってページ内に埋め込むしかなく、こちらで内容の調整はできないんですよね。 aタグじゃなく、リンクはこちらで用意したコンポーネントに差し替えたいってことはできないわけです。

そしてaタグとidを使った、ブラウザ標準機能のページ内リンクは色々と問題を抱えているようです

標準ページ内リンクの問題

  1. 実行する度にurlにidが追記される
  2. Andoidはページ内リンクが一度までに制限されている
  3. cssで位置調整しないとヘッダーと被る

特に後半の 2 と 3 が辛いですね。 Androidでは試したところ確かに一度ページ内リンクで移動すると、二回目以降が動作しませんでした。

Androidのアンカーリンク(ページ内リンク)は1回限り。 | Tips Note by TAM

また、3のモバイル用のヘッダーと被らないよう、見出しの位置を全てずらすのは、面倒な上にcssの記述も煩雑になります。 なのでJSでの実装に変更する必要があったんですね。

本文をパースして目次を自動生成させる

React Scrollと組み合わせてでこうしてやります。 (これは関数部分を後半で解説する修正に差し替えないとbuildでコケるので注意)

TSX
// 標準のLinkだとgatsbyのLinkコンポーネントと名前が被るので、別名に変更
import { Link as ScrollLink } from 'react-scroll'

const create_toc = (html: string) => {
  const parser = new DOMParser()
  const doc = parser.parseFromString(html, 'text/html')
  const match_h_tag_list: NodeListOf<Element> = doc.querySelectorAll(
    'h2, h3, h4, h5'
  )
  const result = []
  for (const i of match_h_tag_list as any) {
    const title = i.textContent
    result.push({ tag_name: i.tagName.toLowerCase(), id: i.id, title })
  }
  return result
}

return(
  <ul>
	{create_toc(html).map((elem) => (
	  <li key={elem.id} css={table_of_contents_main_item_style}>
		<ScrollLink to={elem.id} smooth offset={-63}>
		  <div className={elem.tag_name}>{elem.title}</div>
		</ScrollLink>
	  </li>
	))}
  </ul>
)

内容を簡単に説明すると、create_toc関数から

{
  tag_namge: string // 元のhタグ
  id: string // リンクさせるid
  title: string // 見出しの文字
}[]

というオブジェクトの入ったリストが生成されます。 元の階層構造はなくなってしまうので、tag_namgeをクラス名にして、h3やh4にインデントをつける為に利用してます。

React Scrollでのページ内リンク

react-scrollはLinkというコンポーネントを使ってページ内リンクを作るのですが、そのままだとGatsbyのLinkと名前が被るので、適当にScrollLinkと変更。

TSX
import { Link as ScrollLink } from 'react-scroll'

<ScrollLink to={elem.id} smooth offset={-63}>
  <div className={elem.tag_name}>{elem.title}</div>
</ScrollLink>

toはリンク先のid(#は不要)、 smoothはスムーズスクロールさせる為のboolean(定義があればtrue)、 offsetはモバイル時のヘッダーに被らないように63px分上にズラしてます。 durationを設定すれば移動速度を変更できますが、特に不満がなかったので今回はデフォで。 他にも沢山の機能があるので、必要な場合は公式を参照して下さい。

後は、これでmapを回してリンク付きの目次を生成させて完成! ……の筈でしたが。

gatsby buildでエラー発生

gatsby developをしている時は問題なかったのですが、gatsby buildするとエラーが発生しました。

failed Building static HTML for pages - 11.229s

 ERROR #95313

Building static HTML failed for path "/program/java_script/why_i_started_gatsby"

See our docs page for more info on this error: https://gatsby.dev/debug-html

  10 |
  11 | const create_toc = (html: string) => {
> 12 |   const parser = new DOMParser()
     |                  ^
  13 |   const doc = parser.parseFromString(html, 'text/html')
  14 |   const match_h_tag_list: NodeListOf<Element> = doc.querySelectorAll(
  15 |     'h2, h3, h4, h5'

  WebpackError: ReferenceError: DOMParser is not defined

どうもdevelopはブラウザで実行されているけれども、buildはnode.jsで実行されるので、DOMParserは動かないらしいです。 同じ言語なのに環境で挙動が変わるの辛い……Woozy Face Emoji

A lot of browser functionalities, like DOM manipulations or XHR, are not available natively NodeJS because that is not a typical server task to access the DOM - you'll have to use an external library to do that.

// google翻訳 DOM操作やXHRなどの多くのブラウザー機能はNodeJSでネイティブに使用できません。これは、DOMにアクセスするための一般的なサーバータスクではないためです。これを行うには、外部ライブラリを使用する必要があります。 javascript - Trying to use the DOMParser with node js - Stack Overflow

解決するには、何かDOMPerserの代替になるnpmモジュールを使えとのこと。 私の環境ではjsdomではbuildで別のerrorが出て駄目でしたので、cheerioを使ったら問題なく動きました。 (jsdomでどんなエラーが出ていたかは絶賛没頭中だったので記録してないです)

jsdomがDOMPerserの代替としては正統派らしいので最初に試しましたが、公式によるとcheerioの方が8倍も高速らしいんでこっちでいいですね。

Preliminary end-to-end benchmarks suggest that cheerio is about 8x faster than JSDOM.

// Google翻訳 予備的なエンドツーエンドのベンチマークは、cheerioがJSDOMよりも約8倍速いことを示唆しています。 https://www.npmjs.com/package/cheerio

後、jsdomは「Unpacked Size 2.7 MB」と巨大ですが、比べてcheerioは「Unpacked Size 112 kB」と軽量なのも嬉しいところ。 というか仮に問題なく動いていても、メインのフレームワークでもないモジュール一つでメガサイズなのは辛い。

と言うわけでcheerioをインストール。 (@types/cheerioはts用なのでjsの場合は不要)

npm i cheerio @types/cheerio

上記のcreate_toc関数をこのように修正。

TSX
import cheerio from 'cheerio'

const create_toc = (html: string) => {
  const c_obj = cheerio.load(html)
  const match_obj = c_obj('h2, h3, h4, h5').children('a')
  const result = []
  for (const i of [...Array(match_obj.length).keys()]) {
    result.push({
      tag_name: match_obj[i].parent.name.toLowerCase(),
      id: match_obj[i].parent.attribs.id,
      title: match_obj[i].next.data,
    })
  }
  return result
}

hタグの子要素のaタグを機転にして、親のhタグの要素名とid、中の見出しを所得する形ですね。 tag_name: match_obj[i].parent.name.toLowerCase()はcheerioだと小文字で返ってくるのでtoLowerCase()は不要なんですが、一応惰性でつけてます。

何でcheerioで所得したオブジェクトを直接forで回さずに、[...Array(match_obj.length).keys()]を使っているかというと、TSで型 'Cheerio' には、反復子を返す '[Symbol.iterator]()' メソッドが必要です。ts(2488)というエラーが出るからです。

全部を合わせるとこんな感じです。

TSX
import { Link as ScrollLink } from 'react-scroll'
import cheerio from 'cheerio'

const create_toc = (html: string) => {
  const c_obj = cheerio.load(html)
  const match_obj = c_obj('h2, h3, h4, h5').children('a')
  const result = []
  for (const i of [...Array(match_obj.length).keys()]) {
    result.push({
      tag_name: match_obj[i].parent.name.toLowerCase(),
      id: match_obj[i].parent.attribs.id,
      title: match_obj[i].next.data,
    })
  }
  return result
}

return(
  <ul>
    {create_toc(html).map((elem) => (
      <li key={elem.id} css={table_of_contents_main_item_style}>
        <ScrollLink to={elem.id} smooth offset={-63}>
          <div className={elem.tag_name}>{elem.title}</div>
        </ScrollLink>
      </li>
    ))}
  </ul>
)

無事ビルドも通り、現在の目次はiPhoneでもAndroidでも、問題なく使えるようになっているはずです。(多分)

Top
Back to Top
トップへ戻る