developers blog

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

はじめに

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

※メジャーアップデートによりv6ではここに書かれている内容とは仕様が変わっています。

react-markdown とは

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

事前準備

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

インストール

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

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

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

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

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

1markdown-test
2  ┗ src
3     ┗ App.tsx

App.tsx の変更

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

App.tsx
1import React from 'react';
2import logo from './logo.svg';
3import './App.css';
4
5function App() {
6  return (
7    <div className="App">
8      <header className="App-header">
9        <img src={logo} className="App-logo" alt="logo" />
10        <p>
11          Edit <code>src/App.tsx</code> and save to reload.
12        </p>
13        <a
14          className="App-link"
15          href="https://reactjs.org"
16          target="_blank"
17          rel="noopener noreferrer"
18        >
19          Learn React
20        </a>
21      </header>
22    </div>
23  );
24}
25
26export default App;

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

App.tsx
1import React from 'react';
2import ReactMarkdown from 'react-markdown';
3
4const markdown = `# markdown test
5
6[google](https://google.co.jp)
7
8[about](/about)
9`;
10
11function App() {
12  return (
13    <div className="App">
14      <ReactMarkdown source={markdown} />
15    </div>
16  );
17}
18
19export default App;

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

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

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

linkTarget オプション

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

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

renderer を自作する

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

App.tsx
1const linkBlock = (props: { href: string; children: React.ReactNode }) => {
2  const { href, children } = props;
3
4  if (href.match('http')) {
5    return (
6      <a href={href} target="_blank" rel="noopener noreferrer">
7        {children}
8      </a>
9    );
10  }
11  return <a href={href}>{children}</a>;
12};

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

App.tsx
1<ReactMarkdown
2  source={markdown}
3  renderers={{
4    link: linkBlock,
5  }}
6/>

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

Single Page Application (SPA) の場合

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

App.tsx
1import { Link } from 'react-router-dom';
2
3const linkBlock = (props: { href: string; children: React.ReactNode }) => {
4  const { href, children } = props;
5
6  if (href.match('http')) {
7    return (
8      <a href={href} target="_blank" rel="noopener noreferrer">
9        {children}
10      </a>
11    );
12  }
13  // ページ内リンク
14  if (href.slice(0, 1) == '#') {
15    return <a href={href}>{children}</a>;
16  }
17  return <Link to={href}>{children}</Link>;
18};

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

next.js の場合

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

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

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

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

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

index.js
1import Link from 'next/link';
2
3const linkBlock = (props) => {
4  const { href, children } = props;
5
6  if (href.match('http')) {
7    return (
8      <a href={href} target="_blank">
9        {children}
10      </a>
11    );
12  }
13
14  let child = children;
15  if (Array.isArray(children) && children.length == 1) {
16    child = children[0];
17  }
18  // ページ内リンク
19  if (href.slice(0, 1) == '#') {
20    return <a href={href}>{child}</a>;
21  }    
22  return (
23    <Link href={href}>
24      {child}
25    </Link>
26  );
27};

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