React、Expressアプリでスクリーンショットを撮る方法

2023年8月10日

  • React
  • TypeScript
  • Express
  • Playwright
  • html2canvas

JavaScript の標準機能としてスクリーンショットを撮る機能はないため、ライブラリを使う必要があり、選択肢としてサーバーサイドに頼る方法とクライアントサイドで解決する方法があります。

PuppeteerPlaywrightを使う#

PuppeteerPlaywright は、サーバーサイドでブラウザを操作するためのライブラリです。 これらにはスクリーンショットを撮る機能が備わってるそれを利用します。 このブログのOGPの生成にも Playwright のスクリーンショット機能を使ってます。 サーバーサイドにAPIを作って、React からリクエストを送ることでスクリーンショットを撮ることができます。

bash
1$ npm install playwright
ts
1// server.ts
2
3import express from "express";
4import cors from "cors";
5import playwright from "playwright";
6
7const app = express();
8app.use(express.json());
9app.post('/screenshot', async(res, req, next) => {
10 try {
11 const url = req.query.url;
12 const width = req.query.width;
13 const height = req.query.height;
14
15 if (!url || !width || !height) {
16 throw new Error();
17 }
18
19 // puppeteerの起動
20 const browser = await puppeteer.launch();
21 const page = await browser.newPage();
22 await page.setViewportSize({
23 width: parseInt(width, 10),
24 height: parseInt(height, 10),
25 });
26 await page.goto(url);
27
28 const base64 = (
29 await page.screenshot({
30 fullPage: true,
31 })
32 ).toString("base64");
33 res.send(base64);
34 } catch(e) {
35 next(e);
36 } finally {
37 await browser.close();
38 }
39});
40
41app.listen(8080, () => {
42 console.log("Server is running on port 8080");
43});
ts
1// useScreenshot.ts
2
3import axios from "axios";
4import { useMutation } from "react-query";
5
6export const useScreenshot = () => {
7 return useMutation("getScreenshot", () =>
8 axios.post(
9 `${process.env.API_ENDPOINT}/screenshot?url=${
10 url
11 }&width=${
12 document.body.clientWidth
13 }&height=${
14 document.body.clientHeight
15 }`));
16}

しかし、サーバー内でブラウザーを立ち上げるのでサーバー側の負荷が高くなるデメリットがあります。 また、Docker での設定が面倒だったりイメージの容量が重かったりします。

html2canvasを使う#

html2canvasCanvas API を使ってスクリーンショットを取ってくれるライブラリーです。 サーバー側を介さなくいいので便利です。

bash
1$ npm install html2canvas
ts
1// useScreenshot.ts
2
3import html2canvas from "html2canvas";
4
5export const useScreenshot = () => {
6 const capture = (element: HTMLElement) => {
7 try {
8 if (!element) return;
9 const canvas = await html2canvas(element);
10 return canvas.toDataURL("image/png");
11 } catch (e) {
12 console.error(e);
13 }
14 };
15};
tsx
1import { useScreenshot } from "./useScreenshot";
2
3export const App = () => {
4 const { capture } = useScreenshot();
5 const ref = useRef<HTMLDivElement>(null);
6 const [image, setImage] = useState<string | null>(null);
7 const handleClick = () => {
8 const image = capture(ref.current);
9 setImage(image);
10 };
11
12 return (
13 <div ref={ref}>
14 {/* content */}
15 <button onClick={handleClick}>スクリーンショットを撮る</button>
16 {image && <img src={image} />}
17 </div>
18 )
19}

CORS画像#

自分でhostingしてる画像なら useCORS オプションなどを使って対応できますが、外部の画像を使う場合はCORSのエラーが出てしまいます。 サーバーに頼ることになりますが、proxy オプションを使うことで回避できます。

ts
1// server.ts
2
3...
4
5app.get('/proxy', (res, req, next)=> {
6 try {
7 const url = req.query.url;
8 if (!url) {
9 throw new Error();
10 }
11 const response = await fetch(url);
12 response.arrayBuffer().then((buffer) => {
13 res.send(Buffer.from(buffer));
14 });
15 } catch (e) {
16 next(e);
17 }
18})
19...
tsx
1// useScreenshot.ts
2import html2canvas from "html2canvas";
3import { useState } from "react";
4
5export const useScreenshot = () => {
6 const [isLoading, setIsLoading] = useState(false);
7 const capture = async (element: HTMLElement) => {
8 try {
9 if (!element || !name) return;
10 setIsLoading(true);
11 const canvas = await html2canvas(element, {
12 proxy: `${process.env.API_ENDPOINT}/proxy`,
13 });
14 return canvas.toDataURL("image/png");
15 } catch (e) {
16 console.error(e);
17 } finally {
18 setIsLoading(false);
19 }
20 };
21
22 return { capture, isLoading };
23};

Flexboxのレイアウトが崩れる#

html2canvas はFlexbox要素の文字がズレてしまうことが多かったので、解決のためにレポジトリをクローンして自分で修正して使いました。 トリッキーなので参考程度に。

ts
1// src/render/canvas/canvas-renderer.ts
2
3renderTextWithLetterSpacing(text: TextBounds, letterSpacing: number, baseline: number): void {
4 if (letterSpacing === 0) {
5 this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + baseline / 2 + 2); // <-- ここを修正
6 } else {
7 const letters = segmentGraphemes(text.text);
8 letters.reduce((left, letter) => {
9 this.ctx.fillText(letter, left, text.bounds.top + baseline);
10
11 return left + this.ctx.measureText(letter).width;
12 }, text.bounds.left);
13 }
14}

画像をダウンロードする#

<a> タグの download 属性を使うことで、画像をダウンロードすることができます。 iOSでは画像のダウンロードがUX的に良くないと思ったので、新しいページにレンダリングして長押しで保存できるようにします。

tsx
1import html2canvas from "html2canvas";
2import { useState } from "react";
3import { isIos } from "../utils/isIos";
4
5export const useScreenshot = () => {
6 const [isLoading, setIsLoading] = useState(false);
7 const capture = async (element: HTMLElement) => {
8 try {
9 if (!element) return;
10 setIsLoading(true);
11 const canvas = await html2canvas(element, {
12 proxy: `${process.env.API_ENDPOINT}/proxy`,
13 });
14 const data = canvas.toDataURL("image/png");
15
16 if (isIos()) {
17 const image = new Image();
18 image.src = data;
19 const w = window.open("about:blank");
20 w?.document.write(image.outerHTML);
21 w?.document.close();
22 } else {
23 const link = document.createElement("a");
24 link.download = "download.png"
25 link.href = data;
26 link.click();
27 }
28 } catch (e) {
29 console.error(e);
30 } finally {
31 setIsLoading(false);
32 }
33 };
34
35 return { capture, isLoading };
36};