react-markdown で外部リンクのみ別タブで開く

はじめに

react-markdown で外部リンクのみ別タブで開くために、rendarer を自作していきます。

目次

react-markdown とは

react-markdown は、マークダウンで書かれた文章を react コンポーネントとしてレンダリングするためのライブラリです。

react-markdown

事前準備

説明のため、create-react-app を使用して環境を作成していきます。

インストール

npm を使用し、create-react-app と react-markdown のパッケージをインストールします。
ローカルにインストールしたパッケージを簡単に実行するため、npx パッケージもインストールしておきます。

$ npm i -D create-react-app react-markdown npx

npx create-react-app コマンドを実行し、markdown-test というプロジェクトを作成します。ここでは、--typescript オプションを使用していますが、今回の目的には関係ないので、つけてもつけなくてもどっちでもいいです。

$ npx create-react-app markdown-test --typescript

markdown-test をいう名前のディレクトリが作成され、その中に react のプロジェクトの雛形が作成されます。今回は、src ディレクトリ内の App.tsx を修正していきます。

markdown-test
  ┗ src
     ┗ App.tsx

App.tsx の変更

作成された App.tsx ファイルの中身は以下のようになっています。

App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

これを今回の内容に合わせるため、以下のように編集します。
const markdown = `.....` 内にマークダウン記法でかかれた文章を、ReactMarkdown の source に受け渡して表示するだけの簡単な画面になります。

App.tsx
import React from 'react';
import ReactMarkdown from 'react-markdown';

const markdown = `# markdown test

[google](https://google.co.jp)

[about](/about)
`;

function App() {
    return (
        <div className="App">
            <ReactMarkdown source={markdown} />
        </div>
    );
}

export default App;

この状態で、npm run start コマンドを実行すると、ブラウザが起動し以下の画面が表示されます。

ブラウザに markdown test という文字と、google と about というリンクが表示されている。

この状態で、google のリンクをクリックすると現在のタブに google のページが表示されます。今回は、これを別タブで開くようにします。
about のリンクはページを作成していないので、この状態でクリックしても画面の内容は変わりません。

linkTarget オプション

react-markdown には linkTarget というオプションがあります。ここに以下のように指定すれば、リンクが別タブで開くようになりますが、全てのリンクが別タブで開いてしまうため、今回の目的には使用できません。

App.tsx
<ReactMarkdown source={markdown} linkTarget={"_blank"} />

renderer を自作する

外部リンクのみ別タブで開くようにするために、link renderer を自作します。今回、外部リンクの判定は url に http という文字列が含まれる場合とします。以下のコードを App.tsx に追加します。

App.tsx
......

const linkBlock = (props: { href: string; children: React.ReactNode }) => {
    const { href, children } = props;

    if (href.match('http')) {
        return (
            <a href={href} target="_blank" rel="noopener noreferrer">
                {children}
            </a>
        );
    }
    return <a href={href}>{children}</a>;
};

上記で作成した linkBlock を renderers の link に指定します。

App.tsx
<ReactMarkdown
    source={markdown}
    renderers={{
        link: linkBlock,
    }}
/>

この状態で、ブラウザの google のリンクをクリックすると別タブで開くようになります。about のリンクをクリックしても現在のタブに読み込まれます。

Single Page Application (SPA) の場合

SPA の場合、リンクが <a> タグのため、内部リンクであっても画面が再読み込みされてしまいます。そのため、内部リンクの部分を react-router-dom の Link に変更します。

App.tsx
import { Link } from 'react-router-dom';

......

const linkBlock = (props: { href: string; children: React.ReactNode }) => {
    const { href, children } = props;

    if (href.match('http')) {
        return (
            <a href={href} target="_blank" rel="noopener noreferrer">
                {children}
            </a>
        );
    }
    // ページ内リンク
    if (href.slice(0, 1) == '#') {
        return <a href={href}>{children}</a>;
    }
    return <Link to={href}>{children}</Link>;
};

これで内部リンクをクリックしても、ページが再読み込みされることなく内容が再描画されます。

next.js の場合

next.js の next/link を使用する場合、children をそのまま使用すると以下のようなエラーが表示されます。

Error: React.Children.only expected to receive a single React element child.

next/link の children は単一の element しか受け取れないにもかかわらず、props で渡される children は以下のように配列になっているためです。

[ { '$$typeof': Symbol(react.element),
    type: [Function: TextRenderer],
    key: 'text-5-2-0',
    ref: null,
    props: { nodeKey: 'text-5-2-0', children: 'about', value: 'about' },
    _owner: null,
    _store: {} } ]

そのため、next/link に渡す値に、children の先頭の element を渡すようにします。

index.js
import Link from 'next/link';

......

const linkBlock = (props) => {
    const { href, children } = props;

    if (href.match('http')) {
        return (
            <a href={href} target="_blank">
                {children}
            </a>
        );
    }

    let child = children;
    if (Array.isArray(children) && children.length == 1) {
        child = children[0];
    }
    // ページ内リンク
    if (href.slice(0, 1) == '#') {
        return <a href={href}>{child}</a>;
    }    
    return (
        <Link href={href}>
            <a>{child}</a>
        </Link>
    );
};

これでエラーにならずに内部リンクが表示されます。