본문 바로가기

PWA 프로젝트 셋업하기

codeConnection 2024. 7. 16.

패키지 설치

yarn add next-pwa
yarn add -D webpack

next.config.mjs 설정

import withPWAInit from "next-pwa";

const withPWA = withPWAInit({
    dest: "public",
});

/** @type {import('next').NextConfig} */
const nextConfig = {};

export default withPWA(nextConfig);

public/manifest.json 설정

{
    "name": "My Next.js PWA",
    "short_name": "NextPWA",
    "description": "My awesome Next.js PWA!",
    "icons": [
        {
            "src": "/test_icon.png",
            "type": "image/png",
            "sizes": "192x192"
        },
        {
            "src": "/test_icon.png",
            "type": "image/png",
            "sizes": "512x512"
        }
    ],
    "start_url": "/",
    "background_color": "#ffffff",
    "theme_color": "#000000",
    "display": "standalone"
}
  • 아이콘은 사이즈별로 준비해야 함. 위 예제처럼 하면 안 됨. (안 될 건 없지만 의도한 대로 작동 안 할 수 있음.)
  • 더불어 public 폴더에 png 아이콘을 사이즈별로 준비한다.
  • 이 외에도 다양한 환경에 대응하고 싶다면 객체를 추가해서 사이즈 별로 대응 가능.

app/layout.tsx 설정

import Footer from "@/components/public/Footer";
import Header from "@/components/public/Header";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

// 이 부분
export const viewport: Viewport = {
    themeColor: "black",
    width: "device-width",
    initialScale: 1,
    maximumScale: 1,
    userScalable: false,
    viewportFit: "cover",
};

export const metadata: Metadata = {
    title: "Create Next App",
    description: "Generated by create next app",
    manifest: "/manifest.json",
    icons: {
        icon: "/test_icon.png",
        shortcut: "/test_icon.png",
        apple: "/test_icon.png",
        other: {
            rel: "apple-touch-icon-precomposed",
            url: "/test_icon.png",
        },
    },
};
// 이 부분

export default function RootLayout({
    children,
}: Readonly<{
    children: React.ReactNode;
}>) {
    return (
        <html lang="en">
            <body className={inter.className}>
                <Header />
                {children}
                <Footer />
            </body>
        </html>
    );
}

이곳의 메타데이터 타이틀은 앱 이름임.

public/sw.js

import { clientsClaim } from 'workbox-core';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

clientsClaim();

// self.__WB_MANIFEST is injected by workbox-build during the build process
precacheAndRoute(self.__WB_MANIFEST || []);

// Cache CSS, JS, and web worker requests with a network-first strategy.
registerRoute(
  ({ request }) => request.destination === 'style' || request.destination === 'script' || request.destination === 'worker',
  new NetworkFirst({
    cacheName: 'static-resources',
  })
);

// Cache image files with a cache-first strategy.
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
      }),
    ],
  })
);

// Cache API calls with a network-first strategy.
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    networkTimeoutSeconds: 10,
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
    ],
  })
);

// Cache the start URL with a network-first strategy.
registerRoute(
  '/',
  new NetworkFirst({
    cacheName: 'start-url',
    plugins: [
      {
        cacheWillUpdate: async ({ request, response }) => {
          if (response && response.type === 'opaqueredirect') {
            return new Response(response.body, {
              status: 200,
              statusText: 'OK',
              headers: response.headers,
            });
          }
          return response;
        },
      },
    ],
  })
);

// Cache everything else with a network-only strategy.
registerRoute(
  ({ request }) => true,
  new CacheFirst({
    cacheName: 'catch-all',
  })
);

이 파일은 자동으로 셋업 되지만, 빌드 시 인코딩 되어 다음부터 개발 서버를 돌리면 제대로 동작하지 않을 수 있음. 그럴 때는 이 파일을 계속 스니펫처럼 복-붙해야 할 수 있으니 올려 둠.

(필요 시) PWA 접속 유무 점검하는 유틸 함수 작성

// utils/pwa/isPWA.ts (자유)

export const isPWA = (): boolean => {
  return (
    window.matchMedia("(display-mode: standalone)").matches ||
    (window.navigator as any).standalone === true
  );
};

(필요 시) PWA 접속 유무 점검하는 커스텀 훅 작성

// hooks/useCheckPwa.ts (자유)

import { useEffect, useState } from 'react';

const useCheckPwa = (): boolean => {
  const [isPwa, setIsPwa] = useState(false);

  useEffect(() => {
    const checkPwa = (): boolean => {
      return window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone === true;
    };
    setIsPwa(checkPwa());
  }, []);

  return isPwa;
};

export default useCheckPwa;

 

  • 이렇게 만들어진 훅을 import해서 상태의 값에 따라 추가 로직을 작성하면 됨.
  • 단 한계는 useEffect 훅으로 작성되었기 때문에 화면이 그려지고 나서 로직이 작동하기에 화면에서 한 번 깜빡임이 있을 수 있음. 보완이 필요한 추가 로직임.

댓글