【Next.js】Notionのbookmarkみたいなコンポーネントを作りたい
Notionでリンクを貼ったときに”Create bookmark”で作れるこれ、なんかかっこよくないですか?
リンクからOGPデータを取得していい感じの見た目を表示するのをやってみたかったので、試しに実装してみることにしました。
出来上がったものはこんな感じです。
最終的に使用した技術
- Next.js(Serverless Functions含む)
- TypeScript
- Tailwind CSS
- swr
- JSDOM
- React Loading Skeleton
- Vercel
型定義&モックデータ作成
最初にデザインから必要な情報を抽出し、その型を定義をしていきます。
export type OgpData = {
pageUrl: string; // ページのURLそのもの
title: string; // ページタイトル
description: string; // ページの説明
faviconUrl: string; // ファビコンのURL
ogImgUrl: string; // OGP画像のURL
};
この型定義をもとに適当なモックデータを作成していきます。今回はReactチュートリアルのページのデータを取ってきて、見た目にいろんなパターンが出るように調整したモックを複数用意しました。
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
{
"extends": ["next/core-web-vitals", "plugin:tailwindcss/recommended"]
}
セットアップが終わったら、基本的なViewを担当する、いわゆるPresentationalなコンポーネントを作っていきます。
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を立ち上げて見ていきます。
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;
hoverのアクションも効いていて(真ん中)、全体的にいい感じです。モックデータでいろんなパターンを用意したので、OGP画像がないときやタイトルが長いときなどの対応もカバーできていることがわかります。
getOgp APIを実装
OgpDate型の情報が入ってこれば見た目を表現できるようになったので、データを提供するAPIを実装します。今回はNext.jsのServerless Functions内で実装していきます。また、データをうまく扱うためにJSDOMというライブラリを利用します。
yarn add -D jsdom @types/jsdom
urlを受け取って、OgpDateを返すAPIを実装していきます。今回は「headタグから泥臭く情報を抜き出し、データを整形して返す」というふうになっています。もっとうまいやり方があれば教えて下さい...。
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)
みたいな感じで使えるようにカスタムフックにしてしまいます。
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
の見た目を返す」というコンポーネントです。
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
を返すようにしています。
実際に画面に表示して確かめてみましょう。
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;
うん、いい感じですね。通信エラーが起きたときにもそれなりの見た目を返すことができています。
しかし読み込み時の挙動を観察してみると、通信が発生しない方と比べ、通信が発生する方ではレイアウト崩れが起きていることがわかります。
リロードしている様子
それもそのはず。data
がundifined
なうちは空っぽの見た目を返していたのでした。
// ...
if (!data) return <></>;
// ...
次の段階で、今<></>
を返しているところにいい感じのLoadingコンポーネントを作成して、レイアウト崩れが起きないようにしていきましょう。
Loading Skeletonを実装
スケルトンでLoadingを表現していきます。今回はReact Loading Skeltonを使用します。
yarn add react-loading-skeleton
実際の実装はこんな感じです。Bookmark.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
で呼び出します。
// ...
if (!data) return <Loading />;
// ...
うん、いい感じです。レイアウト崩れがなくなりました。
レスポンシブ対応
これでやりたいことは一通りできたのですが、せっかくなのでレスポンシブ対応していきます。
まず、最初に作ったBookmarkView.tsx
をBookmarkViewDesktop.tsx
とし、新たにBookmarkViewMobile.tsx
を作成していきます。BookmarkViewMobile
は画面幅が一定以上のときに消えるようにしておきます。
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
は画面幅が一定以下のときに消えるようにしておきます
return (
<a
href={pageUrl}
target="_blank"
rel="noreferrer"
+ className="hidden md:block"
>
そしてPresentational Componentの取りまとめ役となるコンポーネントを作ります。
const BookmarkView: VFC<BookmarkViewProps> = ({ ogp }) => (
<>
<BookmarkViewMobile ogp={ogp} />
<BookmarkViewDesktop ogp={ogp} />
</>
);
これで画面幅を狭めてみると、こんな感じです。Notionは画像を表示しないのですが、せっかくデータを取得しているので縦並びに表示してみました。悪くないですね。
Loadingの方も同じように、Mobile版を用意して画面幅で見た目を切り替えるようにします。
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 />
</>
);
ここまでくれば完成です。お疲れさまでした!
デモサイトで見た目を確認してみてください。
また、最終的なコードはこちらをご覧ください。参考資料
Discussion