NDCの企業サイトを、表題にあるNext.jsでの静的サイト生成(SSG)と、AWSでのAmplifyによるホスティングでリニューアルしました。サイトには問い合わせフォームもあり、こちらもAmplifyのサービスを使用して、社内にメールを送信する仕組みを用意しています。
これまでのWebページは、動的コンテンツ更新のためにWordPressを使用して、Web上で記事の編集などを行っていたのですが、PHPのセキュリティ対応など、自社で気を配らなければいけない部分が多くあり運用に手間がかかること、動作が重かったことなど、いくつか問題がありました。
今回の更新では、AWSのマネージドサービスを活用する、ビルド&デプロイを自動化するなど、負荷の低減とセキュリティの向上ができるよう構築を行っています。DXへの取り組みなど、ご覧いただきたいコンテンツをご紹介したいところでもありますが、本記事では、構築時に行った以下のいくつか作りこんだポイントについて、ご紹介します。
Next.jsでのSSGでお知らせ一覧作成
問い合わせフォームの作成
ページスクロール
Youtubeの動画埋め込み時の遅延読み込み
地図に住所やクチコミを表示しない
細かなNext.jsでの開発方法や、完全なソースの掲載については省略させていただきますので、ご了承ください。
Next.jsでのSSGでお知らせ一覧作成
お知らせ一覧や、ニュースの各ページもすべて静的サイトとして生成しています。
過去のお知らせについてはWordPressから出力したデータを使ってページを生成し、新規作成するものは、Next.jsでの新規ページとして作成しています。新・旧含めたデータをJSONで一覧データとして定義し、1ページあたりのページ件数を設定しながら各一覧ページを生成する、ということを行っています。
データ定義の抜粋 "News.json"
固定配置するニュースや、新規作成のページ、WordPressから取得したデータのタイトル部分をJSONに加工しています。このJSONとは別にWordPressから抜き出したデータも、HTMLの本文を文字列にした形で、JSONとして定義しておきます。
[
{
"title": "株式会社日本データコントロールは「数理・データサイエンス・AI教育プログラム」の趣旨に賛同します。\n弊社で一緒に社会を豊かにしていきましょう。",
"id": "https://www.meti.go.jp/policy/it_policy/jinzai/MDASH/mdashsupport.html",
"pubDate": "",
"type": "message",
"wp": false,
"fixed": true
},
{
"title": "ホームページをリニューアルしました。",
"id": "20230126",
"pubDate": "2023/1/26 16:00:00",
"type": "news",
"wp": false
},
・・・
]
News一覧の画面「[pageNo].tsx」抜粋
getStaticPathsは事前にレンダリングするパスのリストを定義します。ここで定義したpathsの分だけ、ページが生成されます。ここでは、ニュースの件数に合わせて、必要なページ件数分、パスのリストとして作成しています。
//ニュースの一覧をimport
import NewsListData from "@/data/News.json";
//ニュースの一覧を10件ずつ分けて、URLとなる「/news/page/<pageNo>」ページ番号を生成
export async function getStaticPaths() {
return {
paths: sliceArray<{ id: string }>(NewsListData, 10).map((news, idx) => {
return {
params: {
pageNo: String(idx + 1),
},
};
}),
fallback: false,
};
}
getStaticPropsはレンダリングに使用するデータを取得する関数です。
ここではページ毎に、分割した件数分のニュース一覧とページ番号を渡して、ページのレンダリング用のデータとしています。
//ページ番号を受け取り、一覧各ページを生成するための、パラメータ作成。
export async function getStaticProps(props: { params: { pageNo: string } }) {
const perPages = sliceArray<{
id: string;
title: string;
type: string;
pubDate: string;
}>(
NewsListData.map((item) => ({
...item,
href: `${MenuInfo.link}/${item.id}`,
})),
10
);
const news = perPages[Number(props.params.pageNo) - 1];
const maxPageNo = perPages.length;
return {
props: {
news,
pageNo: Number(props.params.pageNo),
maxPageNo,
time: new Date().toISOString(),
},
};
}
概念的なコードの断片ですが、以下のような形で作成したパラメータを使用して、全ページを生成しています。企業サイト全体のレイアウトなどは、部品化されており、必要なmeta情報の書き出しなども行っています。
//各ページのパラメータを受け取って、ページのHTML生成
export default function NewsPage(props: {
news: { title: string; pubDate: string; type: NewsType; href: string }[];
pageNo: number;
maxPageNo: number;
time: string;
}) {
const handleChange = (event: ChangeEvent<unknown>, page: number) => {
event.preventDefault();
if (page > 1) {
window.location.href = `/news/page/${page}`;
} else {
window.location.href = `/news`;
}
};
//共通のレイアウトなどもパーツ化しておき、head内のmeta情報を設定する return (
<AppMainLayout
menu={MenuInfo}
time={props.time}
norobot
footer={<></>}
disableLinkColor
>
<NewsList news={props.news}></NewsList>
<Pagination
sx={{ display: "flex", justifyContent: "center", width: "100%", mt: 3 }}
page={props.pageNo}
count={props.maxPageNo}
onChange={handleChange}
/>
</AppMainLayout>
);
}
生成している各画面については、例えばメニューの情報、メニューの先の画面に表示する内容など、なるべくコンテンツが一か所で管理されるよう、データ定義と画面の見た目に関する部分を分けて定義してサイトの生成を行っています。
問い合わせフォームの作成
問い合わせフォームはAmplifyを使用した以下の仕組みで実現しています。
Amplifyで作成できるバックエンドとして、問い合わせをEメールで配信する、Amazon SNSのトピックを作成。
フォーム送信用のGraphQL APIを作成。APIはAWS Lambdaの実行と紐づけられる。
AWS Lambdaの処理では、Amazon SNSに問い合わせ内容を送信。
問い合わせの受信担当者のメールアドレスを Amazon SNSのEメールSubscriptionとして登録し、問い合わせ内容ごとに振り分け可能にしてメール配信。
人手を介さずに、手順を一定として新規のサイト構築や更新を行いたいため、ここに記載された一連の流れをAmplifyのカスタムリソースを活用して、自動で構築されるよう構成しています。
Amazon SNSカスタムリソースの作成
amplify CLIを使用して amplify add customコマンドで、以下のようにAmazon SNSのトピックと、受信用のSubscriptionを定義します。
TopicのdisplayNameはSNSから送信されるメールの送信者として使用されます。EメールSubscriptionではfilterPolicyとして、メッセージの属性の許可リストを設定しています。allowListに設定した属性を持つメッセージのみが配信されるようになります。
export class cdkStack extends cdk.Stack {
constructor(
scope: cdk.Construct,
id: string,
props?: cdk.StackProps,
amplifyResourceProps?: AmplifyHelpers.AmplifyResourceProps
) {
super(scope, id, props);
new cdk.CfnParameter(this, "env", {
type: "String",
description: "Current Amplify CLI env name",
});
const amplifyProjectInfo = AmplifyHelpers.getProjectInfo();
const snsTopicResourceNamePrefix = `topic-${amplifyProjectInfo.projectName}`;
const topic = new sns.Topic(this, "sns-topic", {
topicName: `${snsTopicResourceNamePrefix}-${cdk.Fn.ref("env")}`,
displayName: "Notification from NDC web page",
});
topic.addSubscription(
new subs.EmailSubscription("hoge@localhost.localdomain", {
filterPolicy: {
destination: SubscriptionFilter.stringFilter({
allowlist: [
"type_1",
"type_2",
"type_3",
],
}),
},
})
);
new cdk.CfnOutput(this, "snsTopicArn", {
value: topic.topicArn,
description: "Arn SNS topic",
});
}
}
Lambdaから作成したSNSへPublishできるよう、Lambdaのカスタムポリシーを設定します。
custom-policies.json
[
{
"Action": ["sns:Publish"],
"Resource": ["arn:aws:sns:*:*:topic-yourprojectname-${env}"]
}
]
Lambdaでは、フォームへの入力内容に合わせてメール本文となる文章を編集し、振り分けの属性を設定したうえでAmazon SNSへ送信します。
LambdaのFunction(実装イメージ)
export const handler = async (event: AppSyncResolverEvent<IConnection>) => {
try {
const { company, contact, type } = event.arguments.input;
const env = process.env.ENV as string;
let title = '';
let message = '';
//入力内容に合わせてメール本文を作成
if (type === 'type_1' || type === 'type_2') {
title = 'フォーム1からのお問い合わせ';
message = [
'ホームページのお問い合わせフォーム1より',
'以下の内容でお問合わせを受け付けました。',
'',
'───────────────────────────────────',
'■ お問合わせ内容',
'───────────────────────────────────',
'',
'[お問い合わせ項目]',
`${type === 'type_1' ? '種類1' : '種類2'}`,
'',
'[企業名・学校名]',
`${company}`,
'',
'[お問い合わせ内容]',
`${contact}`,
'',
].join('\n');
}
//その他
else {
title = 'フォーム2からのお問い合わせ';
message = [
'ホームページのお問い合わせフォーム2より',
'以下の内容でお問合わせを受け付けました。',
'',
'───────────────────────────────────',
'■ お問合わせ内容',
'───────────────────────────────────',
'',
'[お問い合わせ項目]',
`種類3`,
'',
'[企業名・学校名]',
`${company}`,
'',
'[お問い合わせ内容]',
`${contact}`,
'',
].join('\n');
}
//typeとして入力の内容をメッセージ属性として振り分けに使用する
await sns
.publish({
TopicArn: `topic-yourprojectname-${env}`,
Subject: title,
Message: message,
MessageAttributes: {
destination: {
DataType: 'String',
StringValue: type,
},
},
})
.promise();
return {
statusCode: 200,
body: 'ok',
};
} catch (err) {
console.log('Error', err);
throw err;
}
必要に応じて、DynamoDBへ書き込みを行うなども、行うと良いかと思います。
ページスクロール
PCで表示した場合の企業サイトトップページでは、画面の幅、高さに表示を合わせる画面全体を使ったデザインと、ページ毎のスクロールを行うようにしています。
画面単位のスクロールについては「fullPage.js」などが使われているように感じますが、今回は画面上にスクロール可能であることを示す「下にスクロール」などの表示を加えず、スクロールバーを表示したままページ単位のスクロールを行うために、独自に実装を行いました。
画面上でのWheelイベントを拾う形で、現在のスクロール位置から、自分が今どの範囲を表示しているか、表示している範囲の末尾までスクロールできているか、などを判定しつつ、次の範囲を表示可能な場合には、requestAnimationFrameとscrollToを組み合わせて、滑らかにスクロールが行われるよう実装しています。
スクロール処理は、以下のサイトを参考に実装させていただきました。
スクロール処理
以下のように、スクロールアニメーションを行っています。
export const smoothScroll = (
targetTop: number, //スクロール先の上端
targetFrame: number, //表示画面の高さを何フレームでスクロールするかの設定
baseHeight: number, //画面の高さ
scrollingElement: Element //スクロール対象のElement
): Promise<void> => {
let position = 0; // スクロールする位置
let progress = 0; // 進捗 0から100
const start = scrollingElement.scrollTop;
const diff = targetTop - start; // 目的の位置までの差分
//画面高さ分を指定のフレームで動かすとして、目的の高さを何フレームで動かすか計算。
const frameNum = Math.abs(Math.round((targetFrame * diff) / baseHeight));
let isUp = diff <= 0; // 上下どちらのスクロールを行うのかは、差分が負の値かどうかで判断することができる
//加速度の計算
const easeOut = (p: number) => {
return p * (2 - p);
};
return new Promise((resolve): void => {
const move = () => {
progress++;
// スクロールする位置の計算 フレーム数、進捗に応じた位置を決定
position = start + diff * easeOut(progress / frameNum);
window.scrollTo(0, position);
if ((isUp && targetTop < position) || (!isUp && position < targetTop)) {
// 現在位置が目的位置より進んでいなければアニメーション続行
requestAnimationFrame(move);
return;
}
resolve();
};
requestAnimationFrame(move); // 初回呼び出し
});
};
iPad等のタッチ端末で実装するには、Wheelイベントではなく、pointerdown、pointermoveのイベント発生時に、スワイプの方向を判定して、同様のスクロールを行います。ただ、こちらは実装してみたものも体験がイマイチだったため、採用には至りませんでした。iPad等のぬるぬる動く慣性付きのスクロールは心地よいですからね・・。
参考までにスワイプの判定は、以下のようにして行うことができます。
スワイプの判定例
CSSでtouch-actionをnoneに設定して、スクロールを止めた上で、pointerdown, pointermove, pointercancelで、スワイプを判定しています。スワイプの判定が終わるまで、画面が動かないので操作に違和感があり、採用していません。
import { RefObject, useEffect, useState } from "react";
export const useSwipe = (props: {
onSwipeDown: () => void;
onSwipeUp: () => void;
scrollElement: RefObject<HTMLElement>;
}) => {
const [swiped, setSwiped] = useState<boolean>(false);
useEffect(() => {
const scrollElement = props.scrollElement.current;
if (!scrollElement) {
return;
}
let startY = -1;
let currentY = -1;
let swiped = false;
scrollElement.style.touchAction = "none";
const pointerDown = (ev: PointerEvent) => {
if (ev.pointerType === "mouse") {
return;
}
setSwiped(true);
startY = -1;
swiped = true;
};
const pointerMove = (ev: PointerEvent) => {
if (startY === -1) {
swiped = true;
startY = ev.pageY;
}
currentY = ev.pageY;
};
const pointerUp = (ev: PointerEvent) => {
if (!swiped) {
return;
}
ev.preventDefault();
if (ev.pointerType === "mouse") {
swiped = false;
startY = -1;
currentY = -1;
setSwiped(false);
return;
}
if (startY < currentY) {
props.onSwipeDown();
} else if (startY > currentY) {
props.onSwipeUp();
}
startY = -1;
currentY = -1;
swiped = false;
setSwiped(false);
};
scrollElement.addEventListener("pointerdown", pointerDown);
scrollElement.addEventListener("pointermove", pointerMove);
scrollElement.addEventListener("pointerup", pointerUp);
scrollElement.addEventListener("pointercancel", pointerUp);
return () => {
scrollElement.removeEventListener("pointerdown", pointerDown);
scrollElement.removeEventListener("pointermove", pointerMove);
scrollElement.removeEventListener("pointerup", pointerUp);
scrollElement.removeEventListener("pointercancel", pointerUp);
};
}, [props, props.scrollElement]);
return swiped;
};
YouTubeの動画埋め込み時の遅延読み込み
本対応はWebページのテストツールである、「Lighthouse」のレポートに従い行ったものです。YouTubeなどの動画の埋め込みは、ページ表示にすぐに読み込みが始まるため、読み込みの完了が遅くなる場合があります。
重い読み込み処理に変わって、ここではYouTube動画のサムネイル画像を代替表示するファサードである「React Lite Youtube Embed」を使用しています。
React Lite YouTube Embedの使用
YouTubeのIDを指定してコンポーネントを使用するだけです。お手軽ですね。
import { EmbedYoutube } from "@/components/EmbedYoutube";
import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css";
・・・
return <EmbedYoutube id="a-P_MG_4Uig" title="みんなの外字"></EmbedYoutube>
地図に住所やクチコミを表示しない
表示領域の都合や、建物までの地図を表示などでクチコミなどの表示を行いたくない場合もあるかと思います。高さを調整することで表示内容を調整しています。
以下は 400px×300pxでGoogleマップを埋め込んでいます。2023年1月現在、住所とクチコミの☆数が表示されます。ピンの位置も隠れてしまっていますね。
以下は 400px × 299pxでの表示です。2023年1月時点では、 300px以下では表示内容が簡略化されるようです。
ただ、確実な方法ではありませんので、確実に表示を調整する場合はGoogle Map Platformなどを活用するのが良いかと思います。
以上、ここまでサイトリニューアルにあたり、作りこんだところなどをご紹介しました。このほかにも、Next.jsのSSGサイトで、metaタグ内にCSPの設定を行っていたり、画面幅に合わせた画像を表示する画像の最適化なども行っています。
今後もウェブサイトにさまざまな改善を加えていきたいと考えています。タブレットのページ単位のスクロールなど、良いやり方が見つかれば、こちらでもご紹介していきます。
Comments