🌊

【Next.js】Notionのbookmarkみたいなコンポーネントを作りたい

2022/01/27に公開

Notionでリンクを貼ったときに”Create bookmark”で作れるこれ、なんかかっこよくないですか?

リンクからOGPデータを取得していい感じの見た目を表示するのをやってみたかったので、試しに実装してみることにしました。

出来上がったものはこんな感じです。

最終的に使用した技術

  • Next.js(Serverless Functions含む)
  • TypeScript
  • Tailwind CSS
  • swr
  • JSDOM
  • React Loading Skeleton
  • Vercel

型定義&モックデータ作成

最初にデザインから必要な情報を抽出し、その型を定義をしていきます。

types.ts
export type OgpData = {
  pageUrl: string; // ページのURLそのもの
  title: string; // ページタイトル
  description: string; // ページの説明
  faviconUrl: string; // ファビコンのURL
  ogImgUrl: string; // OGP画像のURL
};

この型定義をもとに適当なモックデータを作成していきます。今回はReactチュートリアルのページのデータを取ってきて、見た目にいろんなパターンが出るように調整したモックを複数用意しました。

/mocks.ts
import { OgpData } from "./types";

export const mockOgpData: OgpData = {
  pageUrl: "https://beta.reactjs.org/learn/state-as-a-snapshot",
  title: "State as a Snapshot",
  description: "A JavaScript library for building user interfaces",
  faviconUrl: "https://beta.reactjs.org/favicon.ico",
  ogImgUrl: "https://beta.reactjs.org/logo-og.png",
};

export const mockOgpData1: OgpData = {
  pageUrl: "https://beta.reactjs.org/learn/state-as-a-snapshot",
  title:
    "State variables might look like regular JavaScript variables that you can read and write to. However, state behaves more like a snapshot. Setting it does not change the state variable you already have, but instead triggers a re-render.",
  description: "A JavaScript library for building user interfaces",
  faviconUrl: "https://beta.reactjs.org/favicon.ico",
  ogImgUrl: "https://beta.reactjs.org/logo-og.png",
};

export const mockOgpData2: OgpData = {
  pageUrl: "https://beta.reactjs.org/learn/state-as-a-snapshot",
  title: "State as a Snapshot",
  description:
    "State variables might look like regular JavaScript variables that you can read and write to. However, state behaves more like a snapshot. Setting it does not change the state variable you already have, but instead triggers a re-render.",
  faviconUrl: "",
  ogImgUrl: "",
};

export const mockOgpDataList = [mockOgpData, mockOgpData1, mockOgpData2];

基本的なViewを作成

先程作った型定義をもとにUIを定義していき、モックデータを使って検証していきます。
今回は書き味重視でスタイリングにTailwind CSSを使います。

yarn add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

その他configファイルの設定等をします。詳細なセットアップはこちら
ついでにeslint-plugin-tailwindcssも入れときます。オススメ設定を入れておくだけでTailwindのclassNameがソートされるのでおすすめです。

yarn add -D eslint-plugin-tailwindcss
/.eslintrc.json
{
  "extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"]
}

セットアップが終わったら、基本的なViewを担当する、いわゆるPresentationalなコンポーネントを作っていきます。

components/Bookmark.tsx
import { VFC } from "react";
import { OgpData } from "../types";

interface BookmarkViewProps {
  ogp: OgpData;
}

export const BookmarkView: VFC<BookmarkViewProps> = ({ ogp }) => {
  const { title, description, faviconUrl, pageUrl, ogImgUrl } = ogp;
  const w = ogImgUrl ? "w-3/5" : "w-full";
  const ml = faviconUrl ? "ml-2" : "";

return (
    <a href={pageUrl} target="_blank" rel="noreferrer">
      <article className="flex justify-between h-40 rounded border border-gray-400 border-solid">
        <div
          className={`flex flex-col justify-between p-5  hover:bg-gray-100 ${w}`}
        >
          <h3 className="text-2xl truncate">{title}</h3>
          <p className="overflow-hidden h-12 text-base text-gray-500">
            {description}
          </p>
          <div className="flex items-center">
            {faviconUrl && <img src={faviconUrl} className="h-6" alt="" />}
            <p className={`text-base truncate ${ml}`}>{pageUrl}</p>
          </div>
        </div>
        {ogImgUrl && (
          <div className="w-2/5 h-full rounded">
            <img src={ogImgUrl} className="object-cover w-full h-full" alt="" />
          </div>
        )}
      </article>
    </a>
  );

モックデータを使って今作ったコンポーネントを呼び出し、うまく行っているかどうかlocalhostを立ち上げて見ていきます。

/pages/index.tsx
import type { NextPage } from "next";
import Head from "next/head";
import { BookmarkView } from "../components/Bookmark";
import { mockOgpDataList } from "../mocks";

const Home: NextPage = () => {
  return (
    <div>
      <Head>
        <title>Notion Style Bookmark Component</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className="flex flex-col justify-evenly p-2 mx-auto max-w-2xl h-screen">
        {mockOgpDataList.map((ogp, i) => (
          <BookmarkView ogp={ogp} key={i.toString()} />
        ))}
      </main>
    </div>
  );
};

export default Home;


http://localhost:3000/

hoverのアクションも効いていて(真ん中)、全体的にいい感じです。モックデータでいろんなパターンを用意したので、OGP画像がないときやタイトルが長いときなどの対応もカバーできていることがわかります。

getOgp APIを実装

OgpDate型の情報が入ってこれば見た目を表現できるようになったので、データを提供するAPIを実装します。今回はNext.jsのServerless Functions内で実装していきます。また、データをうまく扱うためにJSDOMというライブラリを利用します。

yarn add -D jsdom @types/jsdom

urlを受け取って、OgpDateを返すAPIを実装していきます。今回は「headタグから泥臭く情報を抜き出し、データを整形して返す」というふうになっています。もっとうまいやり方があれば教えて下さい...。

/pages/api/getOgp.ts
import { JSDOM } from "jsdom";

import { OgpData } from "../../types";

import type { NextApiRequest, NextApiResponse } from "next";
import console from "console";

async function getOgp(req: NextApiRequest, res: NextApiResponse<OgpData>) {
  // クエリパラメタからURL情報を受け取り、エンコードする
  const { url } = req.query;
  const encodeURL = encodeURI(url as string);

  // エンコード済みURLに対してリクエストを行い、レスポンスからopgDataを抽出する
  try {
    const response = await fetch(encodeURL)
      .then((res) => res.text())
      .then((text) => {
        const dom = new JSDOM(text);

        // metaタグ、titleタグの要素を取得
        const meta = dom.window.document.head.querySelectorAll("meta");
        const titleTag = dom.window.document.title;

        // nameかpropertyで'og:'という文字列を持っているmetaタグを抽出
        const tagsContainingOg = Array.from(meta).filter((tag) => {
          const property = tag.getAttribute("property");
          const name = tag.getAttribute("name");
          const checkOg = (text: string) => text.substring(0, 3) === "og:";

          return checkOg(property ?? "") || checkOg(name ?? "");
        });

        // OgpDateを抽出
        const ogp = tagsContainingOg.reduce((previous: any, tag: Element) => {
          // property属性かname属性かを判定
          const attr = tag.hasAttribute("property")
            ? tag.getAttribute("property")
            : tag.getAttribute("name");

          // "og:image"などから"og:"を取り除いたものをkeyに用いる
          const key = attr?.trim().replace("og:", "") ?? "";

          // content属性をvalueに用いる
          const content = tag.getAttribute("content") ?? "";
          previous[key] = content;

          return previous;
        }, {});

        // ”https://” を除いた、最初の/まで抜き出す
        const siteUrl = ogp["url"].substring(
          0,
          ogp["url"].indexOf("/", 8)
        ) as string;

        // 多くのサイトはroot/favicon.icoでfaviconを取得できるようになっているらしい
        const faviconPath = "/favicon.ico";

        const ogpData: OgpData = {
          title: titleTag,
          description: ogp["description"] as string,
          faviconUrl: siteUrl + faviconPath,
          ogImgUrl: ogp["image"] as string,
          pageUrl: url as string,
        };

        return ogpData;
      });

    // 返ってきたデータから、title, description, ogImgUrl, faviconUrl, pageeUrlを抽出して返す
    const { pageUrl, title, description, faviconUrl, ogImgUrl } = response;

    res.status(200).json({
      pageUrl,
      title,
      description,
      faviconUrl,
      ogImgUrl,
    });
  } catch (error) {
    // エラーが起きた際にもOgpDate型の情報が返ってくるようにする
    res.status(200).json({
      title: "Sorry!うまく取得できなかったっぽいです🙇‍♂️",
      description: "",
      faviconUrl: "",
      ogImgUrl: "",
      pageUrl: url as string,
    });

    // デバッグ用
    console.log({ error });
  }
}

export default getOgp;

工夫したところは、

  • "og:..."を探すのに、property属性だけでなくname属性も見に行く
  • エラー時でも返すデータの型をOgpDataに保つ

くらいでしょうか。

よくみるとNotionのbookmarkは説明のところに「本文の最初らへん」が来ているのですが、今回はog:descriptionで妥協しました。

全体的に自分ではうまいこと書けなかったので、一番下の参考資料のコードをけっこう拝借しました。ありがとうございました。

ともかく、うまくAPIが実装できていることをlocalhostを立ち上げて確認します。

http://localhost:3000/api/getOgp?url=https://beta.reactjs.org/learn/state-as-a-snapshot

API利用をHooksに隠蔽

作成したAPIはuseSWR経由で利用することにします(手軽でいい感じにしてくれるので)。

yarn add swr

ついでにuseOgp(url)みたいな感じで使えるようにカスタムフックにしてしまいます。

/hooks/useOgp.ts
import useSWR from "swr";

import { OgpData } from "../types";

export function useOgp(url: string) {
  const fetcher = (path: string) => fetch(path).then((res) => res.json());
  const { data, error } = useSWR<OgpData>(`/api/getOgp?url=${url}`, fetcher);

  return { data, error };
}

useSWRのおかげで、「通信中はundifined、終わったらOgpData型」なdataを返せるようになっています。

Hooksを利用してデータを取得し、Viewに流し込む

いわゆるContainer Componentを作っていきます。先程のHooksを用いて、「urlを受け取ってBookmarkの見た目を返す」というコンポーネントです。

/components/Bookmark.tsx
export const Bookmark: VFC<{ url: string }> = ({ url }) => {
  const { data, error } = useOgp(url);

  // for debug
  if (!error) console.log(error);

  if (!data) return <></>;

  return <BookmarkView ogp={data} />;
};

dataは「通信中はundifined、終わったらOgpData型」なので、通信中は空っぽの見た目を返し、きちんと通信が終わった後にはdataの入ったBookmarkViewを返すようにしています。

実際に画面に表示して確かめてみましょう。

/pages/index.tsx
import type { NextPage } from "next";
import Head from "next/head";
import { Bookmark, BookmarkView } from "../components/Bookmark";
import { mockOgpDataList } from "../mocks";

const URL_LIST = [
  "https://beta.reactjs.org/learn/state-as-a-snapshot",
  "https://beta.reactjs.org/learn/state-as-a-snapshot",
  "https://通信エラーが起きてほしいな.com",
];

const Home: NextPage = () => {
  return (
    <div>
      <Head>
        <title>Notion Style Bookmark Component</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className="flex overflow-scroll flex-col justify-evenly p-2 mx-auto max-w-2xl">
        <h1 className="mx-auto text-2xl">通信が発生しない方</h1>
        {mockOgpDataList.map((ogp, i) => (
          <BookmarkView ogp={ogp} key={i.toString()} />
        ))}

        <h1 className="mx-auto text-     <main className="flex overflow-scroll justify-evenly items-center p-2 mx-auto h-screen">
        <div className="max-w-2xl">
          <h1 className="text-2xl text-center">通信が発生しない方</h1>

          {mockOgpDataList.map((ogp, i) => (
            <BookmarkView ogp={ogp} key={i.toString()} />
          ))}
        </div>
        <div className="max-w-2xl">
          <h1 className="text-2xl text-center">通信が発生する方</h1>
          {URL_LIST.map((url, i) => (
            <Bookmark url={url} key={i.toString()} />
          ))}
        </div>
      </main>2xl">通信が発生する方</h1>
        {URL_LIST.map((url, i) => (
          <Bookmark url={url} key={i.toString()} />
        ))}
      </main>
    </div>
  );
};

export default Home;

うん、いい感じですね。通信エラーが起きたときにもそれなりの見た目を返すことができています。

しかし読み込み時の挙動を観察してみると、通信が発生しない方と比べ、通信が発生する方ではレイアウト崩れが起きていることがわかります。


リロードしている様子

それもそのはず。dataundifinedなうちは空っぽの見た目を返していたのでした。

Bookmark.tsx
// ...
  if (!data) return <></>;
// ...

次の段階で、今<></>を返しているところにいい感じのLoadingコンポーネントを作成して、レイアウト崩れが起きないようにしていきましょう。

Loading Skeletonを実装

スケルトンでLoadingを表現していきます。今回はReact Loading Skeltonを使用します。

yarn add react-loading-skeleton

実際の実装はこんな感じです。Bookmark.tsxのレイアウトをなるべくそのまま採用します。

components/Loading.tsx
export const Loading: VFC = () => {
  return (
    <article className="flex justify-between w-full h-40 rounded border border-gray-400 border-solid">
      <div className="flex flex-col justify-between p-5 w-3/5">
        {/* title用 */}
        <div className="w-3/5">
          <Skeleton className="w-full" />
        </div>

        {/* description用 */}
        <Skeleton count={2} className="w-full" />

        {/* pageUrl用 */}
        <Skeleton className="w-full" />
      </div>

      {/* ogpImg用 */}
      <div className="w-2/5 h-full rounded">
        <Skeleton className="h-full " />
      </div>
    </article>
  );
};

これをBookmark.tsxで呼び出します。

Bookmark.tsx
// ...
  if (!data) return <Loading />;
// ...

うん、いい感じです。レイアウト崩れがなくなりました。

レスポンシブ対応

これでやりたいことは一通りできたのですが、せっかくなのでレスポンシブ対応していきます。
まず、最初に作ったBookmarkView.tsxBookmarkViewDesktop.tsxとし、新たにBookmarkViewMobile.tsxを作成していきます。BookmarkViewMobileは画面幅が一定以上のときに消えるようにしておきます。

/components/Bookmark.tsx
export const BookmarkViewMobile: VFC<BookmarkViewProps> = ({ ogp }) => {
  const { title, description, faviconUrl, pageUrl, ogImgUrl } = ogp;

  const ml = faviconUrl ? "ml-2" : "";

  return (
    <a href={pageUrl} target="_blank" rel="noreferrer" className="md:hidden">
      <article className="flex flex-col justify-between rounded border border-gray-400 border-solid">
        {ogImgUrl && (
          <div className="object-cover w-full h-40 rounded">
            <img src={ogImgUrl} className="object-cover w-full h-full" alt="" />
          </div>
        )}
        <div
          className={`flex flex-col justify-between p-5 h-40  hover:bg-gray-100 w-full`}
        >
          <h3 className="text-xl truncate">{title}</h3>
          <p className="overflow-hidden h-12 text-base text-gray-500">
            {description}
          </p>
          <div className="flex items-center">
            {faviconUrl && <img src={faviconUrl} className="h-6" alt="" />}
            <p className={`text-base truncate ${ml}`}>{pageUrl}</p>
          </div>
        </div>
      </article>
    </a>
  );
};

そしてBookmarkViewDesktopは画面幅が一定以下のときに消えるようにしておきます

/components/Bookmark.tsx > BookmarkViewDesktop
  return (
    <a
      href={pageUrl}
      target="_blank"
      rel="noreferrer"
+     className="hidden md:block"
    >

そしてPresentational Componentの取りまとめ役となるコンポーネントを作ります。

/components/Bookmark.tsx
const BookmarkView: VFC<BookmarkViewProps> = ({ ogp }) => (
  <>
    <BookmarkViewMobile ogp={ogp} />
    <BookmarkViewDesktop ogp={ogp} />
  </>
);

これで画面幅を狭めてみると、こんな感じです。Notionは画像を表示しないのですが、せっかくデータを取得しているので縦並びに表示してみました。悪くないですね。

Loadingの方も同じように、Mobile版を用意して画面幅で見た目を切り替えるようにします。

/components/Loading.tsx
import { VFC } from "react";
import Skeleton from "react-loading-skeleton";

import "react-loading-skeleton/dist/skeleton.css";

export const LoadingDesktop: VFC = () => {
  return (
    <div className="hidden md:block">
      <article className="flex justify-between w-full h-40 rounded border border-gray-400 border-solid ">
        <div className="flex flex-col justify-between p-5 w-3/5">
          {/* title用 */}
          <div className="w-3/5">
            <Skeleton className="w-full" />
          </div>

          {/* description用 */}
          <Skeleton count={2} className="w-full" />

          {/* pageUrl用 */}
          <Skeleton className="w-full" />
        </div>

        {/* ogpImg用 */}
        <div className="w-2/5 h-full rounded">
          <Skeleton className="h-full " />
        </div>
      </article>
    </div>
  );
};

export const LoadingMobile = () => {
  return (
    <div className="md:hidden">
      <article className="flex flex-col justify-between rounded border border-gray-400 border-solid">
        {/* ogpImg用 */}
        <div className="w-full h-40 rounded">
          <Skeleton className="w-full h-full" />
        </div>

        <div className="flex flex-col justify-between p-5 w-full h-40 hover:bg-gray-100">
          {/* title用 */}
          <Skeleton />

          {/* description用 */}
          <Skeleton count={2} />

          {/* pageUrl用 */}
          <Skeleton />
        </div>
      </article>
    </div>
  );
};

// Presentational Component Container
export const Loading = () => (
  <>
    <LoadingMobile />
    <LoadingDesktop />
  </>
);

ここまでくれば完成です。お疲れさまでした!

デモサイトで見た目を確認してみてください。
https://notion-style-bookmark-component.vercel.app/
また、最終的なコードはこちらをご覧ください。
https://github.com/HajimexxxNakagawa/Notion-Style-Bookmark-Component

参考資料

https://yutaaaaa.dev/ogp-embed
https://qiita.com/ksyunnnn/items/bfe2b9c568e97bb6b494

Discussion